@payez/next-mvp 4.1.1 → 4.1.2

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.
@@ -1,408 +1,434 @@
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
- }
30
-
31
- /**
32
- * Build Better Auth social providers from IDP config.
33
- */
34
- export function buildBetterAuthProviders(
35
- config: IDPClientConfig
36
- ): Record<string, BetterAuthSocialProvider> {
37
- const providers: Record<string, BetterAuthSocialProvider> = {};
38
-
39
- for (const oauth of config.oauthProviders || []) {
40
- if (!oauth.enabled) continue;
41
- const name = oauth.provider.toLowerCase();
42
- providers[name] = {
43
- clientId: oauth.clientId,
44
- clientSecret: oauth.clientSecret,
45
- scope: oauth.scopes?.split(' '),
46
- };
47
- }
48
-
49
- return providers;
50
- }
51
-
52
- /**
53
- * Optional configuration for `createBetterAuthInstance`.
54
- *
55
- * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
56
- * The host app supplies its own `sendMagicLink` callback — typically a
57
- * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
58
- * Omit the `magicLink` key entirely to skip the plugin; the consuming
59
- * app will not have a magic-link flow.
60
- */
61
- export interface CreateBetterAuthInstanceOptions {
62
- magicLink?: MagicLinkOptions;
63
- }
64
-
65
- /**
66
- * Create Better Auth instance from IDP config.
67
- *
68
- * No database runs in stateless mode with JWE cookie cache.
69
- * Call after getIDPClientConfig() resolves.
70
- */
71
- export function createBetterAuthInstance(
72
- idpConfig: IDPClientConfig,
73
- opts: CreateBetterAuthInstanceOptions = {}
74
- ) {
75
- const appSlug = idpConfig.clientSlug || getAppSlug();
76
-
77
- // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
78
- // Must include /api/auth since that's where the catch-all route is mounted
79
- const rawBaseURL = process.env.BETTER_AUTH_URL
80
- || idpConfig.baseClientUrl
81
- || `http://localhost:${process.env.PORT || '3000'}`;
82
- const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
83
-
84
- return betterAuth({
85
- baseURL,
86
- secret: idpConfig.authSecret as string,
87
-
88
- socialProviders: buildBetterAuthProviders(idpConfig),
89
-
90
- // Trust the app's own origin + any configured base URL
91
- trustedOrigins: [
92
- rawBaseURL,
93
- baseURL,
94
- ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
95
- 'http://localhost:3000',
96
- 'http://localhost:3400',
97
- 'http://localhost:3600',
98
- ],
99
-
100
- // Redis-backed session storage via secondaryStorage
101
- secondaryStorage: {
102
- get: async (key: string) => {
103
- try {
104
- return await getRedis().get(`ba:${appSlug}:${key}`);
105
- } catch { return null; }
106
- },
107
- set: async (key: string, value: string, ttl?: number) => {
108
- try {
109
- const redis = getRedis();
110
- if (ttl) {
111
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
112
- } else {
113
- await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
114
- }
115
- } catch { /* Redis unavailable — cookie cache still works */ }
116
- },
117
- delete: async (key: string) => {
118
- try {
119
- await getRedis().del(`ba:${appSlug}:${key}`);
120
- } catch { /* ignore */ }
121
- },
122
- },
123
-
124
- session: {
125
- cookieCache: {
126
- enabled: true,
127
- maxAge: 300,
128
- refreshCache: false,
129
- },
130
- },
131
-
132
- // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
133
- advanced: {
134
- cookiePrefix: appSlug,
135
- cookies: {
136
- session_token: {
137
- name: `${appSlug}.session-token`,
138
- },
139
- },
140
- },
141
-
142
- plugins: [
143
- nextCookies(),
144
- ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
145
- ],
146
- });
147
- }
148
-
149
- /**
150
- * Better Auth is always enabled (NextAuth removed in 4.0).
151
- */
152
- export function isBetterAuthEnabled(): boolean {
153
- return true;
154
- }
155
-
156
- /**
157
- * Get Better Auth Next.js route handlers (GET, POST).
158
- * Initializes Better Auth from IDP config on first call, caches the instance.
159
- */
160
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
- let cachedInstance: any = null;
162
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
- let initPromise: Promise<any> | null = null;
164
- let configuredOpts: CreateBetterAuthInstanceOptions = {};
165
-
166
- // Expose for server-side session access (decode-session.ts)
167
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
- export { cachedInstance as __betterAuthInstance };
169
-
170
- /**
171
- * Configure Better Auth instance options for this process.
172
- *
173
- * Must be called before the first auth request — before
174
- * `getBetterAuthInstance()` caches an instance. Typically called once at
175
- * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
176
- * server bootstrap hook.
177
- *
178
- * Throws if called after the instance has already been resolved: options
179
- * cannot be applied retroactively.
180
- */
181
- export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
182
- if (cachedInstance) {
183
- throw new Error(
184
- '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
185
- 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
186
- );
187
- }
188
- configuredOpts = opts;
189
- }
190
-
191
- export async function getBetterAuthInstance() {
192
- if (cachedInstance) return cachedInstance;
193
-
194
- if (!initPromise) {
195
- initPromise = getIDPClientConfig(true).then(config => {
196
- const instance = createBetterAuthInstance(config, configuredOpts);
197
- cachedInstance = instance;
198
- console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
199
- return instance;
200
- });
201
- }
202
-
203
- return initPromise;
204
- }
205
-
206
- /**
207
- * Get flag-gated auth handler for Next.js route.
208
- *
209
- * When USE_BETTER_AUTH=true, returns Better Auth handlers.
210
- * Otherwise returns null (auth disabled).
211
- *
212
- * Usage in host app route:
213
- * ```ts
214
- * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
215
- *
216
- * export async function GET(req: Request) {
217
- * const ba = await getBetterAuthHandler();
218
- * if (ba) return ba.GET(req);
219
- * }
220
- * ```
221
- */
222
- export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
223
- if (!isBetterAuthEnabled()) return null;
224
-
225
- const auth = await getBetterAuthInstance();
226
- return toNextJsHandler(auth);
227
- }
228
-
229
- /**
230
- * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
231
- *
232
- * Call this from the OAuth callback route AFTER better-auth has processed the
233
- * callback and created the session. Reads the session token from the Set-Cookie
234
- * header of the response to find the BA Redis key.
235
- *
236
- * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
237
- */
238
- export async function exchangeOAuthForIdpTokens(
239
- sessionToken: string,
240
- provider: string = 'google'
241
- ): Promise<boolean> {
242
- try {
243
- const config = await getIDPClientConfig();
244
- const appSlug = config.clientSlug || getAppSlug();
245
- const baKey = `ba:${appSlug}:${sessionToken}`;
246
-
247
- // Read the BA session from Redis
248
- const baRaw = await getRedis().get(baKey).catch(() => null);
249
- if (!baRaw) {
250
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
251
- return false;
252
- }
253
-
254
- const baData = JSON.parse(baRaw);
255
- const email = baData?.user?.email;
256
- const name = baData?.user?.name;
257
- const image = baData?.user?.image;
258
- const baUserId = baData?.session?.userId || baData?.user?.id;
259
-
260
- if (!email) {
261
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
262
- return false;
263
- }
264
-
265
- // Call IDP oauth-callback
266
- const idpUrl = process.env.IDP_URL || '';
267
- if (!idpUrl) {
268
- console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
269
- return false;
270
- }
271
-
272
- console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
273
-
274
- const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
275
- method: 'POST',
276
- headers: { 'Content-Type': 'application/json' },
277
- body: JSON.stringify({
278
- provider,
279
- provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
280
- email,
281
- name,
282
- image,
283
- client_id: config.clientSlug || String(config.clientId),
284
- }),
285
- });
286
-
287
- const oauthResText = await oauthRes.text();
288
- console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
289
-
290
- if (!oauthRes.ok) {
291
- console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
292
- return false;
293
- }
294
-
295
- let idpData: any;
296
- try { idpData = JSON.parse(oauthResText); } catch { return false; }
297
- const result = idpData?.data?.result || idpData?.data || idpData;
298
-
299
- if (!result?.access_token) {
300
- console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
301
- return false;
302
- }
303
-
304
- // Build IDP token data
305
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? false;
306
- const idpTokenData = {
307
- idpAccessToken: result.access_token,
308
- idpRefreshToken: result.refresh_token,
309
- idpAccessTokenExpires: result.expires_in
310
- ? Date.now() + result.expires_in * 1000
311
- : Date.now() + 15 * 60 * 1000,
312
- userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
313
- email: result.user?.email || result.email || email,
314
- name: result.user?.full_name || result.user?.name || result.name || name,
315
- roles: result.user?.roles || result.roles || [],
316
- mfaVerified: !requiresTwoFactor,
317
- };
318
-
319
- // Store in BA Redis session (for decodeSession)
320
- baData.idpTokens = idpTokenData;
321
- await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
322
-
323
- // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
324
- // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
325
- try {
326
- const { getSessionPrefix } = await import('../lib/app-slug');
327
- const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
328
- await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
329
- ...idpTokenData,
330
- oauthProvider: provider,
331
- }));
332
- } catch (canonicalErr) {
333
- console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
334
- }
335
-
336
- console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
337
- return true;
338
- } catch (err) {
339
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
340
- return false;
341
- }
342
- }
343
-
344
- /**
345
- * Create a production-ready GET handler for the auth catch-all route.
346
- *
347
- * Wraps better-auth's GET handler with:
348
- * - OAuth state error recovery (redirects to login instead of error page)
349
- * - IDP token exchange after successful OAuth callback
350
- *
351
- * Usage in host app:
352
- * ```ts
353
- * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
354
- * export const GET = createAuthGetHandler('/account-auth/login');
355
- * export async function POST(req: Request) {
356
- * const ba = await getBetterAuthHandler();
357
- * return ba!.POST(req);
358
- * }
359
- * ```
360
- */
361
- export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
362
- return async function GET(request: Request): Promise<Response> {
363
- const ba = await getBetterAuthHandler();
364
- if (!ba) {
365
- return new Response('Auth handler not configured', { status: 500 });
366
- }
367
-
368
- const response = await ba.GET(request);
369
-
370
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
371
- if (response.status === 302) {
372
- const location = response.headers.get('location') || '';
373
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
374
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
375
- return Response.redirect(new URL(loginPath, request.url), 302);
376
- }
377
- }
378
-
379
- // After successful OAuth callback: exchange Google identity for IDP tokens
380
- const url = new URL(request.url);
381
- if (url.pathname.includes('/callback/') && response.status === 302) {
382
- try {
383
- const auth = await getBetterAuthInstance();
384
- if (auth?.api?.getSession) {
385
- const setCookies = response.headers.getSetCookie?.() || [];
386
- const cookieHeader = setCookies
387
- .map((c: string) => c.split(';')[0])
388
- .join('; ');
389
-
390
- const headers = new Headers();
391
- headers.set('cookie', cookieHeader);
392
-
393
- const session = await auth.api.getSession({ headers });
394
- if (session?.session?.token) {
395
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
396
- await exchangeOAuthForIdpTokens(session.session.token);
397
- } else {
398
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
399
- }
400
- }
401
- } catch (err: any) {
402
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
403
- }
404
- }
405
-
406
- return response;
407
- };
408
- }
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
+ cookieCache: {
149
+ enabled: true,
150
+ maxAge: 300,
151
+ refreshCache: false,
152
+ },
153
+ },
154
+
155
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
156
+ advanced: {
157
+ cookiePrefix: appSlug,
158
+ cookies: {
159
+ session_token: {
160
+ name: `${appSlug}.session-token`,
161
+ },
162
+ },
163
+ },
164
+
165
+ plugins: [
166
+ nextCookies(),
167
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
168
+ ],
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Better Auth is always enabled (NextAuth removed in 4.0).
174
+ */
175
+ export function isBetterAuthEnabled(): boolean {
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * Get Better Auth Next.js route handlers (GET, POST).
181
+ * Initializes Better Auth from IDP config on first call, caches the instance.
182
+ */
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ let cachedInstance: any = null;
185
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
+ let initPromise: Promise<any> | null = null;
187
+ let configuredOpts: CreateBetterAuthInstanceOptions = {};
188
+
189
+ // Expose for server-side session access (decode-session.ts)
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
+ export { cachedInstance as __betterAuthInstance };
192
+
193
+ /**
194
+ * Configure Better Auth instance options for this process.
195
+ *
196
+ * Must be called before the first auth request — before
197
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
198
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
199
+ * server bootstrap hook.
200
+ *
201
+ * Throws if called after the instance has already been resolved: options
202
+ * cannot be applied retroactively.
203
+ */
204
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
205
+ if (cachedInstance) {
206
+ throw new Error(
207
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
208
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
209
+ );
210
+ }
211
+ configuredOpts = opts;
212
+ }
213
+
214
+ export async function getBetterAuthInstance() {
215
+ if (cachedInstance) return cachedInstance;
216
+
217
+ if (!initPromise) {
218
+ initPromise = getIDPClientConfig(true).then(config => {
219
+ const instance = createBetterAuthInstance(config, configuredOpts);
220
+ cachedInstance = instance;
221
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
222
+ return instance;
223
+ });
224
+ }
225
+
226
+ return initPromise;
227
+ }
228
+
229
+ /**
230
+ * Get flag-gated auth handler for Next.js route.
231
+ *
232
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
233
+ * Otherwise returns null (auth disabled).
234
+ *
235
+ * Usage in host app route:
236
+ * ```ts
237
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
238
+ *
239
+ * export async function GET(req: Request) {
240
+ * const ba = await getBetterAuthHandler();
241
+ * if (ba) return ba.GET(req);
242
+ * }
243
+ * ```
244
+ */
245
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
246
+ if (!isBetterAuthEnabled()) return null;
247
+
248
+ const auth = await getBetterAuthInstance();
249
+ return toNextJsHandler(auth);
250
+ }
251
+
252
+ /**
253
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
254
+ *
255
+ * Call this from the OAuth callback route AFTER better-auth has processed the
256
+ * callback and created the session. Reads the session token from the Set-Cookie
257
+ * header of the response to find the BA Redis key.
258
+ *
259
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
260
+ */
261
+ export async function exchangeOAuthForIdpTokens(
262
+ sessionToken: string,
263
+ provider: string = 'google'
264
+ ): Promise<boolean> {
265
+ try {
266
+ const config = await getIDPClientConfig();
267
+ const appSlug = config.clientSlug || getAppSlug();
268
+ const baKey = `ba:${appSlug}:${sessionToken}`;
269
+
270
+ // Read the BA session from Redis
271
+ const baRaw = await getRedis().get(baKey).catch(() => null);
272
+ if (!baRaw) {
273
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
274
+ return false;
275
+ }
276
+
277
+ const baData = JSON.parse(baRaw);
278
+ const email = baData?.user?.email;
279
+ const name = baData?.user?.name;
280
+ const image = baData?.user?.image;
281
+ const baUserId = baData?.session?.userId || baData?.user?.id;
282
+
283
+ if (!email) {
284
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
285
+ return false;
286
+ }
287
+
288
+ // Call IDP oauth-callback
289
+ const idpUrl = process.env.IDP_URL || '';
290
+ if (!idpUrl) {
291
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
292
+ return false;
293
+ }
294
+
295
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
296
+
297
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({
301
+ provider,
302
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
303
+ email,
304
+ name,
305
+ image,
306
+ client_id: config.clientSlug || String(config.clientId),
307
+ }),
308
+ });
309
+
310
+ const oauthResText = await oauthRes.text();
311
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
312
+
313
+ if (!oauthRes.ok) {
314
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
315
+ return false;
316
+ }
317
+
318
+ let idpData: any;
319
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
320
+ const result = idpData?.data?.result || idpData?.data || idpData;
321
+
322
+ if (!result?.access_token) {
323
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
324
+ return false;
325
+ }
326
+
327
+ // Build IDP token data
328
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? false;
329
+ const idpTokenData = {
330
+ idpAccessToken: result.access_token,
331
+ idpRefreshToken: result.refresh_token,
332
+ idpAccessTokenExpires: result.expires_in
333
+ ? Date.now() + result.expires_in * 1000
334
+ : Date.now() + 15 * 60 * 1000,
335
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
336
+ email: result.user?.email || result.email || email,
337
+ name: result.user?.full_name || result.user?.name || result.name || name,
338
+ image: image,
339
+ roles: result.user?.roles || result.roles || [],
340
+ mfaVerified: !requiresTwoFactor,
341
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
342
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
343
+ };
344
+
345
+ // Store in BA Redis session (for decodeSession)
346
+ baData.idpTokens = idpTokenData;
347
+ await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
348
+
349
+ // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
350
+ // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
351
+ try {
352
+ const { getSessionPrefix } = await import('../lib/app-slug');
353
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
354
+ await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
355
+ ...idpTokenData,
356
+ oauthProvider: provider,
357
+ }));
358
+ } catch (canonicalErr) {
359
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
360
+ }
361
+
362
+ console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
363
+ return true;
364
+ } catch (err) {
365
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
366
+ return false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Create a production-ready GET handler for the auth catch-all route.
372
+ *
373
+ * Wraps better-auth's GET handler with:
374
+ * - OAuth state error recovery (redirects to login instead of error page)
375
+ * - IDP token exchange after successful OAuth callback
376
+ *
377
+ * Usage in host app:
378
+ * ```ts
379
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
380
+ * export const GET = createAuthGetHandler('/account-auth/login');
381
+ * export async function POST(req: Request) {
382
+ * const ba = await getBetterAuthHandler();
383
+ * return ba!.POST(req);
384
+ * }
385
+ * ```
386
+ */
387
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
388
+ return async function GET(request: Request): Promise<Response> {
389
+ const ba = await getBetterAuthHandler();
390
+ if (!ba) {
391
+ return new Response('Auth handler not configured', { status: 500 });
392
+ }
393
+
394
+ const response = await ba.GET(request);
395
+
396
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
397
+ if (response.status === 302) {
398
+ const location = response.headers.get('location') || '';
399
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
400
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
401
+ return Response.redirect(new URL(loginPath, request.url), 302);
402
+ }
403
+ }
404
+
405
+ // After successful OAuth callback: exchange Google identity for IDP tokens
406
+ const url = new URL(request.url);
407
+ if (url.pathname.includes('/callback/') && response.status === 302) {
408
+ try {
409
+ const auth = await getBetterAuthInstance();
410
+ if (auth?.api?.getSession) {
411
+ const setCookies = response.headers.getSetCookie?.() || [];
412
+ const cookieHeader = setCookies
413
+ .map((c: string) => c.split(';')[0])
414
+ .join('; ');
415
+
416
+ const headers = new Headers();
417
+ headers.set('cookie', cookieHeader);
418
+
419
+ const session = await auth.api.getSession({ headers });
420
+ if (session?.session?.token) {
421
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
422
+ await exchangeOAuthForIdpTokens(session.session.token);
423
+ } else {
424
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
425
+ }
426
+ }
427
+ } catch (err: any) {
428
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
429
+ }
430
+ }
431
+
432
+ return response;
433
+ };
434
+ }