@payez/next-mvp 4.1.10 → 4.1.20

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 (181) hide show
  1. package/dist/api-handlers/account/profile.d.ts +38 -0
  2. package/dist/api-handlers/account/profile.js +73 -45
  3. package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
  4. package/dist/api-handlers/account/recovery/send-code.js +52 -18
  5. package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
  6. package/dist/api-handlers/account/recovery/verify-code.js +52 -18
  7. package/dist/api-handlers/account/verify-email.js +15 -1
  8. package/dist/api-handlers/account/verify-sms.js +15 -1
  9. package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
  10. package/dist/api-handlers/auth/passkey-signin.js +97 -0
  11. package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
  12. package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
  13. package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
  14. package/dist/api-handlers/mfa/backup-codes.js +40 -0
  15. package/dist/api-handlers/mfa/methods.d.ts +3 -0
  16. package/dist/api-handlers/mfa/methods.js +41 -0
  17. package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
  18. package/dist/api-handlers/mfa/totp-confirm.js +41 -0
  19. package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
  20. package/dist/api-handlers/mfa/totp-enroll.js +41 -0
  21. package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
  22. package/dist/api-handlers/mfa/totp-verify.js +134 -0
  23. package/dist/api-handlers/passkeys/list.d.ts +7 -0
  24. package/dist/api-handlers/passkeys/list.js +29 -0
  25. package/dist/api-handlers/passkeys/manage.d.ts +3 -0
  26. package/dist/api-handlers/passkeys/manage.js +54 -0
  27. package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
  28. package/dist/api-handlers/passkeys/register-options.js +41 -0
  29. package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
  30. package/dist/api-handlers/passkeys/register-verify.js +40 -0
  31. package/dist/api-handlers/session/viability.js +19 -0
  32. package/dist/auth/better-auth.d.ts +68 -5
  33. package/dist/auth/better-auth.js +271 -45
  34. package/dist/auth/utils/idp-client.d.ts +13 -22
  35. package/dist/auth/utils/idp-client.js +12 -82
  36. package/dist/client/better-auth-client.d.ts +14 -2
  37. package/dist/lib/ensure-fresh-access-token.js +13 -2
  38. package/dist/lib/session-store.js +28 -21
  39. package/dist/lib/startup-init.js +19 -19
  40. package/dist/lib/token-lifecycle.d.ts +2 -0
  41. package/dist/lib/token-lifecycle.js +61 -7
  42. package/dist/models/SessionModel.d.ts +17 -0
  43. package/dist/pages/login/page.d.ts +10 -1
  44. package/dist/pages/login/page.js +69 -80
  45. package/dist/pages/verify-code/page.d.ts +10 -1
  46. package/dist/pages/verify-code/page.js +72 -4
  47. package/dist/routes/auth/passkey-signin.d.ts +13 -0
  48. package/dist/routes/auth/passkey-signin.js +18 -0
  49. package/dist/routes/auth/viability.d.ts +2 -0
  50. package/dist/routes/auth/viability.js +88 -0
  51. package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
  52. package/dist/routes/mfa/backup-code-verify.js +5 -0
  53. package/dist/routes/mfa/backup-codes.d.ts +10 -0
  54. package/dist/routes/mfa/backup-codes.js +14 -0
  55. package/dist/routes/mfa/methods.d.ts +21 -0
  56. package/dist/routes/mfa/methods.js +10 -0
  57. package/dist/routes/mfa/totp-confirm.d.ts +10 -0
  58. package/dist/routes/mfa/totp-confirm.js +14 -0
  59. package/dist/routes/mfa/totp-enroll.d.ts +10 -0
  60. package/dist/routes/mfa/totp-enroll.js +14 -0
  61. package/dist/routes/mfa/totp-verify.d.ts +14 -0
  62. package/dist/routes/mfa/totp-verify.js +18 -0
  63. package/dist/routes/passkeys/list.d.ts +10 -0
  64. package/dist/routes/passkeys/list.js +14 -0
  65. package/dist/routes/passkeys/manage.d.ts +18 -0
  66. package/dist/routes/passkeys/manage.js +13 -0
  67. package/dist/routes/passkeys/register-options.d.ts +10 -0
  68. package/dist/routes/passkeys/register-options.js +14 -0
  69. package/dist/routes/passkeys/register-verify.d.ts +10 -0
  70. package/dist/routes/passkeys/register-verify.js +14 -0
  71. package/dist/server/auth.d.ts +2 -2
  72. package/dist/server/decode-session.js +37 -3
  73. package/package.json +958 -893
  74. package/scripts/check-internal-url-usage.sh +73 -73
  75. package/src/api/index.ts +17 -17
  76. package/src/api-handlers/account/masked-info.ts +45 -45
  77. package/src/api-handlers/account/profile.ts +99 -80
  78. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  79. package/src/api-handlers/account/recovery/send-code.ts +73 -25
  80. package/src/api-handlers/account/recovery/verify-code.ts +73 -25
  81. package/src/api-handlers/account/reset-password.ts +23 -23
  82. package/src/api-handlers/account/send-code.ts +76 -76
  83. package/src/api-handlers/account/validate-password.ts +118 -118
  84. package/src/api-handlers/account/verify-email.ts +15 -1
  85. package/src/api-handlers/account/verify-sms.ts +15 -1
  86. package/src/api-handlers/admin/stats.ts +249 -249
  87. package/src/api-handlers/anon/preferences.ts +123 -123
  88. package/src/api-handlers/auth/jwks.ts +20 -20
  89. package/src/api-handlers/auth/login.ts +240 -240
  90. package/src/api-handlers/auth/passkey-signin.ts +98 -0
  91. package/src/api-handlers/auth/validate.ts +43 -43
  92. package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
  93. package/src/api-handlers/mfa/backup-codes.ts +41 -0
  94. package/src/api-handlers/mfa/methods.ts +46 -0
  95. package/src/api-handlers/mfa/totp-confirm.ts +42 -0
  96. package/src/api-handlers/mfa/totp-enroll.ts +42 -0
  97. package/src/api-handlers/mfa/totp-verify.ts +156 -0
  98. package/src/api-handlers/passkeys/list.ts +31 -0
  99. package/src/api-handlers/passkeys/manage.ts +54 -0
  100. package/src/api-handlers/passkeys/register-options.ts +42 -0
  101. package/src/api-handlers/passkeys/register-verify.ts +41 -0
  102. package/src/api-handlers/session/viability.ts +161 -142
  103. package/src/auth/better-auth.ts +708 -455
  104. package/src/auth/unauthenticated-routes.ts +2 -2
  105. package/src/auth/utils/idp-client.ts +13 -95
  106. package/src/client/better-auth-client.ts +70 -70
  107. package/src/client/fetchWithSession.ts +21 -21
  108. package/src/client/index.ts +13 -13
  109. package/src/client/useAnonSession.ts +131 -131
  110. package/src/components/admin/VibeAdminContext.tsx +87 -87
  111. package/src/config/logging-config.ts +82 -82
  112. package/src/config/unauthenticated-routes.ts +19 -19
  113. package/src/edge/internal-api-url.ts +65 -65
  114. package/src/edge/middleware.ts +42 -42
  115. package/src/hooks/useProfile.ts +75 -75
  116. package/src/lib/anon-session.ts +213 -213
  117. package/src/lib/auth-secret.ts +137 -137
  118. package/src/lib/demo-mode.ts +17 -17
  119. package/src/lib/ensure-fresh-access-token.ts +13 -2
  120. package/src/lib/idp-client-config.ts +559 -559
  121. package/src/lib/idp-fetch.ts +126 -126
  122. package/src/lib/internal-api.ts +171 -171
  123. package/src/lib/jwt-decode-client.ts +45 -45
  124. package/src/lib/rate-limit-service.ts +9 -9
  125. package/src/lib/redis.ts +26 -26
  126. package/src/lib/refresh-token-validator.ts +64 -64
  127. package/src/lib/secret-validation.ts +8 -8
  128. package/src/lib/session-store.ts +702 -695
  129. package/src/lib/session.ts +34 -34
  130. package/src/lib/startup-init.ts +251 -251
  131. package/src/lib/token-expiry.ts +40 -40
  132. package/src/lib/token-lifecycle.ts +533 -470
  133. package/src/lib/types/api-responses.ts +336 -336
  134. package/src/middleware/auth-decision.ts +43 -43
  135. package/src/models/DecodedAccessToken.ts +16 -16
  136. package/src/models/SessionModel.ts +282 -264
  137. package/src/pages/admin-roles/index.ts +9 -9
  138. package/src/pages/login/page.tsx +123 -228
  139. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  140. package/src/pages/verify-code/page.tsx +71 -5
  141. package/src/routes/account/index.ts +31 -31
  142. package/src/routes/account/masked-info.ts +36 -36
  143. package/src/routes/account/send-code.ts +39 -39
  144. package/src/routes/account/verify-email.ts +40 -40
  145. package/src/routes/account/verify-sms.ts +40 -40
  146. package/src/routes/auth/index.ts +22 -22
  147. package/src/routes/auth/passkey-signin.ts +13 -0
  148. package/src/routes/auth/refresh.ts +48 -48
  149. package/src/routes/auth/session.ts +166 -166
  150. package/src/routes/auth/viability.ts +89 -0
  151. package/src/routes/index.ts +17 -17
  152. package/src/routes/mfa/backup-code-verify.ts +1 -0
  153. package/src/routes/mfa/backup-codes.ts +10 -0
  154. package/src/routes/mfa/methods.ts +22 -0
  155. package/src/routes/mfa/totp-confirm.ts +10 -0
  156. package/src/routes/mfa/totp-enroll.ts +10 -0
  157. package/src/routes/mfa/totp-verify.ts +14 -0
  158. package/src/routes/passkeys/list.ts +10 -0
  159. package/src/routes/passkeys/manage.ts +23 -0
  160. package/src/routes/passkeys/register-options.ts +10 -0
  161. package/src/routes/passkeys/register-verify.ts +10 -0
  162. package/src/routes/session/index.ts +7 -7
  163. package/src/routes/session/refresh-viability.ts +17 -17
  164. package/src/server/auth.ts +373 -373
  165. package/src/server/decode-session.ts +235 -202
  166. package/src/services/signalrActivityService.ts +258 -258
  167. package/src/theme/ThemeProvider.tsx +39 -39
  168. package/src/theme/default.ts +33 -33
  169. package/src/theme/index.ts +31 -31
  170. package/src/theme/types.ts +69 -69
  171. package/src/theme/useTheme.ts +57 -57
  172. package/src/theme/utils.ts +40 -40
  173. package/src/types/api.ts +12 -12
  174. package/src/types/auth.d.ts +23 -23
  175. package/src/types/auth.ts +22 -22
  176. package/src/types/logging.ts +11 -11
  177. package/src/types/recovery.ts +54 -54
  178. package/src/utils/api.ts +353 -353
  179. package/src/utils/circuitBreaker.ts +40 -40
  180. package/src/utils/error-message.ts +108 -108
  181. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,455 +1,708 @@
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
- // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
347
- // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
348
- const hasRefreshToken = !!result.refresh_token;
349
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
350
- const idpTokenData = {
351
- idpAccessToken: result.access_token,
352
- idpRefreshToken: result.refresh_token,
353
- idpAccessTokenExpires: result.expires_in
354
- ? Date.now() + result.expires_in * 1000
355
- : Date.now() + 15 * 60 * 1000,
356
- userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
357
- email: result.user?.email || result.email || email,
358
- name: result.user?.full_name || result.user?.name || result.name || name,
359
- image: image,
360
- roles: result.user?.roles || result.roles || [],
361
- mfaVerified: !requiresTwoFactor,
362
- idpClientId: result.client_id ? String(result.client_id) : undefined,
363
- merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
364
- };
365
-
366
- // Store in BA Redis session (for decodeSession)
367
- baData.idpTokens = idpTokenData;
368
- await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
369
-
370
- // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
371
- // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
372
- try {
373
- const { getSessionPrefix } = await import('../lib/app-slug');
374
- const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
375
- await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
376
- ...idpTokenData,
377
- oauthProvider: provider,
378
- }));
379
- } catch (canonicalErr) {
380
- console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
381
- }
382
-
383
- console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
384
- return true;
385
- } catch (err) {
386
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
387
- return false;
388
- }
389
- }
390
-
391
- /**
392
- * Create a production-ready GET handler for the auth catch-all route.
393
- *
394
- * Wraps better-auth's GET handler with:
395
- * - OAuth state error recovery (redirects to login instead of error page)
396
- * - IDP token exchange after successful OAuth callback
397
- *
398
- * Usage in host app:
399
- * ```ts
400
- * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
401
- * export const GET = createAuthGetHandler('/account-auth/login');
402
- * export async function POST(req: Request) {
403
- * const ba = await getBetterAuthHandler();
404
- * return ba!.POST(req);
405
- * }
406
- * ```
407
- */
408
- export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
409
- return async function GET(request: Request): Promise<Response> {
410
- const ba = await getBetterAuthHandler();
411
- if (!ba) {
412
- return new Response('Auth handler not configured', { status: 500 });
413
- }
414
-
415
- const response = await ba.GET(request);
416
-
417
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
418
- if (response.status === 302) {
419
- const location = response.headers.get('location') || '';
420
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
421
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
422
- return Response.redirect(new URL(loginPath, request.url), 302);
423
- }
424
- }
425
-
426
- // After successful OAuth callback: exchange Google identity for IDP tokens
427
- const url = new URL(request.url);
428
- if (url.pathname.includes('/callback/') && response.status === 302) {
429
- try {
430
- const auth = await getBetterAuthInstance();
431
- if (auth?.api?.getSession) {
432
- const setCookies = response.headers.getSetCookie?.() || [];
433
- const cookieHeader = setCookies
434
- .map((c: string) => c.split(';')[0])
435
- .join('; ');
436
-
437
- const headers = new Headers();
438
- headers.set('cookie', cookieHeader);
439
-
440
- const session = await auth.api.getSession({ headers });
441
- if (session?.session?.token) {
442
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
443
- await exchangeOAuthForIdpTokens(session.session.token);
444
- } else {
445
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
446
- }
447
- }
448
- } catch (err: any) {
449
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
450
- }
451
- }
452
-
453
- return response;
454
- };
455
- }
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 { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
15
+ import { betterAuth } from 'better-auth';
16
+ import { nextCookies } from 'better-auth/next-js';
17
+ import { toNextJsHandler } from 'better-auth/next-js';
18
+ import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
19
+ import type { IDPClientConfig } from '../lib/idp-client-config';
20
+ import { getIDPClientConfig } from '../lib/idp-client-config';
21
+ import { getAppSlug } from '../lib/app-slug';
22
+ import { getRedis } from '../lib/redis';
23
+ import { SESSION_TTL } from '../lib/session-store';
24
+
25
+ /**
26
+ * Better Auth social provider config shape.
27
+ */
28
+ export interface BetterAuthSocialProvider {
29
+ clientId: string;
30
+ clientSecret: string;
31
+ scope?: string[];
32
+ prompt?: string;
33
+ accessType?: 'offline' | 'online';
34
+ hd?: string;
35
+ }
36
+
37
+ /**
38
+ * Build Better Auth social providers from IDP config.
39
+ */
40
+ /**
41
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
42
+ *
43
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
44
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
45
+ * for the IDP to require a provider-signed id_token and verify it itself. This
46
+ * is the client half: capture the token Better Auth already receives, and
47
+ * forward it. Additive — the IDP ignores it until its gate flips.
48
+ *
49
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
50
+ * an id_token is credential material. Writing it to the session record would put
51
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
52
+ * and it would outlive the request that needed it. ALS scopes it to the single
53
+ * request, and it is gone when the request ends — nothing to expire, nothing to
54
+ * scrub, nothing to leak.
55
+ *
56
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
57
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
58
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
59
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
60
+ * function. Outside that scope the store is undefined and the body simply omits
61
+ * id_token, which is the correct additive behaviour.
62
+ */
63
+ const idTokenScope = new AsyncLocalStorage<{ idToken?: string }>();
64
+
65
+ export function buildBetterAuthProviders(
66
+ config: IDPClientConfig
67
+ ): Record<string, BetterAuthSocialProvider> {
68
+ const providers: Record<string, BetterAuthSocialProvider> = {};
69
+
70
+ for (const oauth of config.oauthProviders || []) {
71
+ if (!oauth.enabled) continue;
72
+ const name = oauth.provider.toLowerCase();
73
+ const additionalParams = oauth.additionalParams ?? {};
74
+ const rawPrompt = additionalParams.prompt;
75
+ const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
76
+ const rawHostedDomain = additionalParams.hd;
77
+
78
+ // Ensure profile scope is present for Google so avatar image is returned
79
+ const scopes = oauth.scopes?.split(' ') || [];
80
+ if (name === 'google' && !scopes.includes('profile')) {
81
+ scopes.push('profile');
82
+ }
83
+
84
+ providers[name] = {
85
+ clientId: oauth.clientId,
86
+ clientSecret: oauth.clientSecret,
87
+ scope: scopes.length > 0 ? scopes : undefined,
88
+ // Google is overly eager to reuse the last account unless we
89
+ // explicitly ask for account selection on each social login.
90
+ prompt: typeof rawPrompt === 'string'
91
+ ? rawPrompt
92
+ : name === 'google'
93
+ ? 'select_account'
94
+ : undefined,
95
+ accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
96
+ hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
97
+ };
98
+
99
+ /**
100
+ * CAPTURE THE RAW id_token — card 138635, client half.
101
+ *
102
+ * getUserInfo is the ONLY provider hook that receives the token set.
103
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
104
+ * signed JWT cannot be reconstructed from them. We need the raw token
105
+ * because the IDP must verify the signature ITSELF — a client-decoded
106
+ * claim is just another assertable field, which is the defect restated.
107
+ *
108
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
109
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
110
+ * returns our result verbatim, so everything the default did is now ours to
111
+ * do — including the `hd` hosted-domain restriction.
112
+ *
113
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
114
+ * above, so a client can restrict sign-in to a Google Workspace domain.
115
+ * Silently dropping it would let ANY Google account into a client that had
116
+ * restricted itself — a security regression introduced by a security fix.
117
+ * (Measured on 93: zero clients set it today. That is a reason to be
118
+ * careful, not a reason to skip it — it is configurable and unused, which
119
+ * is exactly the state in which a regression goes unnoticed.)
120
+ *
121
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
122
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
123
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
124
+ * default would return true unconditionally, so omitting it is equivalent
125
+ * by the library's own definition rather than by our judgement.
126
+ *
127
+ * A client that DOES set hd keeps the stock provider, keeps its domain
128
+ * restriction, and simply does not contribute an id_token yet. Losing a
129
+ * capability is an acceptable cost; silently losing a security control is
130
+ * not. (better-auth does not re-export the predicate through its public
131
+ * types, and reimplementing a security check is how two copies drift — so
132
+ * the honest move is to not need it.)
133
+ */
134
+ if (name === 'google' && !providers[name].hd) {
135
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
136
+ if (!token?.idToken) return null;
137
+
138
+ // The only new behaviour: hand the raw token to this request's scope.
139
+ const scope = idTokenScope.getStore();
140
+ if (scope) scope.idToken = token.idToken;
141
+
142
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
143
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
144
+ // KEEP, never what we ACCEPT.
145
+ const claims = decodeJwt(token.idToken) as Record<string, any>;
146
+ return {
147
+ user: {
148
+ id: claims.sub,
149
+ name: claims.name,
150
+ email: claims.email,
151
+ image: claims.picture,
152
+ emailVerified: claims.email_verified,
153
+ },
154
+ data: claims,
155
+ };
156
+ };
157
+ }
158
+ }
159
+
160
+ return providers;
161
+ }
162
+
163
+ /**
164
+ * Optional configuration for `createBetterAuthInstance`.
165
+ *
166
+ * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
167
+ * The host app supplies its own `sendMagicLink` callback — typically a
168
+ * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
169
+ * Omit the `magicLink` key entirely to skip the plugin; the consuming
170
+ * app will not have a magic-link flow.
171
+ */
172
+ export interface CreateBetterAuthInstanceOptions {
173
+ magicLink?: MagicLinkOptions;
174
+ /**
175
+ * Extra Better Auth plugins supplied by the consuming app.
176
+ *
177
+ * WHY THIS EXISTS — session minting is not delegable any other way. BA's
178
+ * `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
179
+ * endpoint machinery constructs, so an app that must mint a session for an
180
+ * identity established OUTSIDE BA's built-in providers (passkey sign-in being
181
+ * the first) cannot do it from a route handler. It needs a real BA endpoint,
182
+ * and endpoints only arrive via plugins.
183
+ *
184
+ * Deliberately GENERIC. The package gains "an app may supply plugins" rather
185
+ * than any knowledge of passkeys — the alternative was teaching this file
186
+ * about a specific auth method, which is how a package accumulates a consumer's
187
+ * feature list.
188
+ *
189
+ * The alternative to this option is an app hand-rolling the session cookie
190
+ * (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
191
+ * Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
192
+ * so this option exists specifically to make that shortcut unnecessary.
193
+ */
194
+ plugins?: Parameters<typeof betterAuth>[0]['plugins'];
195
+ }
196
+
197
+ /**
198
+ * Create Better Auth instance from IDP config.
199
+ *
200
+ * No database — runs in stateless mode with JWE cookie cache.
201
+ * Call after getIDPClientConfig() resolves.
202
+ */
203
+ export function createBetterAuthInstance(
204
+ idpConfig: IDPClientConfig,
205
+ opts: CreateBetterAuthInstanceOptions = {}
206
+ ) {
207
+ const appSlug = idpConfig.clientSlug || getAppSlug();
208
+
209
+ // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
210
+ // Must include /api/auth since that's where the catch-all route is mounted
211
+ const rawBaseURL = process.env.BETTER_AUTH_URL
212
+ || idpConfig.baseClientUrl
213
+ || `http://localhost:${process.env.PORT || '3000'}`;
214
+ const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
215
+
216
+ return betterAuth({
217
+ baseURL,
218
+ secret: idpConfig.authSecret as string,
219
+
220
+ socialProviders: buildBetterAuthProviders(idpConfig),
221
+
222
+ // Trust the app's own origin + any configured base URL
223
+ trustedOrigins: [
224
+ rawBaseURL,
225
+ baseURL,
226
+ ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
227
+ 'http://localhost:3000',
228
+ 'http://localhost:3400',
229
+ 'http://localhost:3600',
230
+ ],
231
+
232
+ // Redis-backed session storage via secondaryStorage
233
+ secondaryStorage: {
234
+ get: async (key: string) => {
235
+ try {
236
+ return await getRedis().get(`ba:${appSlug}:${key}`);
237
+ } catch { return null; }
238
+ },
239
+ set: async (key: string, value: string, ttl?: number) => {
240
+ try {
241
+ const redis = getRedis();
242
+ if (ttl) {
243
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
244
+ } else {
245
+ await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
246
+ }
247
+ } catch { /* Redis unavailable — cookie cache still works */ }
248
+ },
249
+ delete: async (key: string) => {
250
+ try {
251
+ await getRedis().del(`ba:${appSlug}:${key}`);
252
+ } catch { /* ignore */ }
253
+ },
254
+ },
255
+
256
+ session: {
257
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
258
+ //
259
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
260
+ // a decoded session in process memory for 5 minutes, bypassing Redis
261
+ // reads during the window. Consequence: if the canonical app session
262
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
263
+ // Better Auth's in-memory copy stayed alive — and callers got
264
+ // contradictory answers depending on whether they consulted Better
265
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
266
+ // visible in production traces: viability 200 + idp-token 200 +
267
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
268
+ //
269
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
270
+ // read on the secondary-storage path. Latency cost is acceptable
271
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
272
+ // workarounds in every consumer app — already shipped in idealvibe.online
273
+ // at b6a91f6.
274
+ cookieCache: {
275
+ enabled: false,
276
+ maxAge: 300,
277
+ refreshCache: false,
278
+ },
279
+ },
280
+
281
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
282
+ advanced: {
283
+ cookiePrefix: appSlug,
284
+ cookies: {
285
+ session_token: {
286
+ name: `${appSlug}.session-token`,
287
+ },
288
+ },
289
+ },
290
+
291
+ plugins: [
292
+ nextCookies(),
293
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
294
+ // App-supplied plugins last: they may depend on the built-ins above, and
295
+ // ordering is BA's own concern once they are in the array.
296
+ ...(opts.plugins ?? []),
297
+ ],
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Better Auth is always enabled (NextAuth removed in 4.0).
303
+ */
304
+ export function isBetterAuthEnabled(): boolean {
305
+ return true;
306
+ }
307
+
308
+ /**
309
+ * Get Better Auth Next.js route handlers (GET, POST).
310
+ * Initializes Better Auth from IDP config on first call, caches the instance.
311
+ */
312
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
+ let cachedInstance: any = null;
314
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
+ let initPromise: Promise<any> | null = null;
316
+ let configuredOpts: CreateBetterAuthInstanceOptions = {};
317
+
318
+ // Expose for server-side session access (decode-session.ts)
319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
+ export { cachedInstance as __betterAuthInstance };
321
+
322
+ /**
323
+ * Configure Better Auth instance options for this process.
324
+ *
325
+ * Must be called before the first auth request — before
326
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
327
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
328
+ * server bootstrap hook.
329
+ *
330
+ * Throws if called after the instance has already been resolved: options
331
+ * cannot be applied retroactively.
332
+ */
333
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
334
+ if (cachedInstance) {
335
+ throw new Error(
336
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
337
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
338
+ );
339
+ }
340
+ configuredOpts = opts;
341
+ }
342
+
343
+ export async function getBetterAuthInstance() {
344
+ if (cachedInstance) return cachedInstance;
345
+
346
+ if (!initPromise) {
347
+ initPromise = getIDPClientConfig(true).then(config => {
348
+ const instance = createBetterAuthInstance(config, configuredOpts);
349
+ cachedInstance = instance;
350
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
351
+ return instance;
352
+ });
353
+ }
354
+
355
+ return initPromise;
356
+ }
357
+
358
+ /**
359
+ * Get flag-gated auth handler for Next.js route.
360
+ *
361
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
362
+ * Otherwise returns null (auth disabled).
363
+ *
364
+ * Usage in host app route:
365
+ * ```ts
366
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
367
+ *
368
+ * export async function GET(req: Request) {
369
+ * const ba = await getBetterAuthHandler();
370
+ * if (ba) return ba.GET(req);
371
+ * }
372
+ * ```
373
+ */
374
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
375
+ if (!isBetterAuthEnabled()) return null;
376
+
377
+ const auth = await getBetterAuthInstance();
378
+ return toNextJsHandler(auth);
379
+ }
380
+
381
+ /**
382
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
383
+ *
384
+ * Call this from the OAuth callback route AFTER better-auth has processed the
385
+ * callback and created the session. Reads the session token from the Set-Cookie
386
+ * header of the response to find the BA Redis key.
387
+ *
388
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
389
+ */
390
+ export async function exchangeOAuthForIdpTokens(
391
+ sessionToken: string,
392
+ provider: string = 'google'
393
+ ): Promise<boolean> {
394
+ try {
395
+ const config = await getIDPClientConfig();
396
+ const appSlug = config.clientSlug || getAppSlug();
397
+ const baKey = `ba:${appSlug}:${sessionToken}`;
398
+
399
+ // Read the BA session from Redis
400
+ const baRaw = await getRedis().get(baKey).catch(() => null);
401
+ if (!baRaw) {
402
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
403
+ return false;
404
+ }
405
+
406
+ const baData = JSON.parse(baRaw);
407
+ const email = baData?.user?.email;
408
+ const name = baData?.user?.name;
409
+ const image = baData?.user?.image;
410
+ const baUserId = baData?.session?.userId || baData?.user?.id;
411
+
412
+ if (!email) {
413
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
414
+ return false;
415
+ }
416
+
417
+ // Call IDP oauth-callback
418
+ const idpUrl = process.env.IDP_URL || '';
419
+ if (!idpUrl) {
420
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
421
+ return false;
422
+ }
423
+
424
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
425
+
426
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
427
+ method: 'POST',
428
+ headers: { 'Content-Type': 'application/json' },
429
+ body: JSON.stringify({
430
+ provider,
431
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
432
+ email,
433
+ name,
434
+ image,
435
+ client_id: config.clientSlug || String(config.clientId),
436
+ /**
437
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
438
+ *
439
+ * Every other field in this body is an ASSERTION. The endpoint mints an
440
+ * IDP bearer from them today with no cryptographic check of any kind,
441
+ * which is why it is a complete authentication bypass and why it has
442
+ * been publicly reachable for 488 days.
443
+ *
444
+ * This field is the only one the IDP can VERIFY: signature against
445
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
446
+ * from the verified claims. Additive — the IDP ignores it until its
447
+ * gate flips, so this cannot break a login on its own.
448
+ *
449
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
450
+ * decoded email_verified. A client-computed verification flag is a body
451
+ * field with a better story: an attacker posts email_verified:true and
452
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
453
+ * the fix is theatre.
454
+ *
455
+ * Absent when this runs outside the OAuth callback scope — the key is
456
+ * simply omitted rather than sent null, so "we never captured one" stays
457
+ * distinguishable from "the provider issued none".
458
+ */
459
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
460
+ }),
461
+ });
462
+
463
+ const oauthResText = await oauthRes.text();
464
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
465
+
466
+ if (!oauthRes.ok) {
467
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
468
+ return false;
469
+ }
470
+
471
+ let idpData: any;
472
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
473
+ const result = idpData?.data?.result || idpData?.data || idpData;
474
+
475
+ if (!result?.access_token) {
476
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
477
+ return false;
478
+ }
479
+
480
+ // Build IDP token data
481
+ // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
482
+ // !hasRefreshToken was a reliable "2FA required" signal.
483
+ //
484
+ // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
485
+ // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
486
+ // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
487
+ // bypassing the 2FA gate entirely.
488
+ //
489
+ // FIX: Always set mfaVerified=false on the initial OAuth exchange.
490
+ // The tenant's require_2fa setting is enforced by the viability endpoint
491
+ // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
492
+ // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
493
+ // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
494
+ // IDP correctly withholds refresh_token until post-2FA.
495
+ const hasRefreshToken = !!result.refresh_token;
496
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
497
+ const idpTokenData = {
498
+ idpAccessToken: result.access_token,
499
+ idpRefreshToken: result.refresh_token,
500
+ idpAccessTokenExpires: result.expires_in
501
+ ? Date.now() + result.expires_in * 1000
502
+ : Date.now() + 15 * 60 * 1000,
503
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
504
+ email: result.user?.email || result.email || email,
505
+ name: result.user?.full_name || result.user?.name || result.name || name,
506
+ image: image,
507
+ roles: result.user?.roles || result.roles || [],
508
+ mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
509
+ // Cards 141688/141741 — RELAYED, NOT DERIVED.
510
+ //
511
+ // The IDP already decides this (ExternalAuthController:943 and :2024,
512
+ // WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
513
+ // confirmed method exists, null = fully authenticated. It was emitted
514
+ // here and dropped on this line — `idpTokenData` is built field-by-field,
515
+ // so anything not named is silently discarded. Both downstream consumers
516
+ // (the middleware gate and the verify-code page) then reasoned in its
517
+ // absence and sent zero-method users to a page they cannot act on.
518
+ //
519
+ // WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
520
+ // BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
521
+ // `result.nextStep` here would be `undefined` forever and fail silently —
522
+ // the same camel/snake break `requires_two_factor` already cost us once.
523
+ //
524
+ // Do NOT recompute this from a method query. The IDP owns the fact; it is
525
+ // already usable-aware (confirmed and not revoked, per DotNetPert), which
526
+ // is the distinction that decides correctness: a revoked-TOTP user has
527
+ // count>0 but usable=0, and deriving it locally would let through exactly
528
+ // the users we must challenge.
529
+ // No `?? null`: if the response has no `next_step` at all (older IDP
530
+ // build, or a path that does not set it), that is UNKNOWN, not "fully
531
+ // authenticated". Defaulting it to null here would manufacture a
532
+ // clean-bill-of-health the IDP never gave and fail open at the gate.
533
+ nextStep: result.next_step,
534
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
535
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
536
+ };
537
+
538
+ // Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
539
+ const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
540
+ if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
541
+ return stored;
542
+ } catch (err) {
543
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
544
+ return false;
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
550
+ * function never acquires tokens and never decides who the caller is.
551
+ *
552
+ * ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
553
+ *
554
+ * `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
555
+ * POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
556
+ * the second half is reusable, and the first half is actively dangerous to
557
+ * reuse:
558
+ *
559
+ * oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
560
+ * cryptographic verification of any kind (card 138635).
561
+ *
562
+ * Passkey sign-in already holds something strictly better — a grant issued by
563
+ * `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
564
+ * signature against a registered credential. Routing that through
565
+ * oauth-callback to obtain a session would DOWNGRADE a cryptographically
566
+ * verified assertion into an unverified one, make passkey sign-in weaker than
567
+ * its own ceremony, and inherit 138635 wholesale.
568
+ *
569
+ * That is not hypothetical: "do what the OAuth path does" is the obvious
570
+ * implementation and it is the wrong one. Splitting acquisition from storage is
571
+ * what makes the wrong one hard to write by accident.
572
+ *
573
+ * ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
574
+ *
575
+ * A Better Auth session must ALREADY EXIST for `sessionToken`. This function
576
+ * enriches; it does not create. Better Auth owns session creation and cookie
577
+ * signing, and nothing in this package should replicate either — the cookie is
578
+ * `{token}.{base64(hmac)}` with standard padding, signed with the runtime
579
+ * `authSecret` from Redis, and hand-rolling that produces a session which
580
+ * decodes but does not authenticate.
581
+ *
582
+ * @param sessionToken the Better Auth session token (from its Set-Cookie)
583
+ * @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
584
+ * verified route. Built field-by-field by the caller so an
585
+ * unnamed field cannot ride along silently.
586
+ * @param authProvider provenance label recorded on the canonical row. Written
587
+ * to the historical `oauthProvider` key, which predates
588
+ * non-OAuth callers; the NAME is legacy, the VALUE should
589
+ * state what actually authenticated ('passkey', 'google').
590
+ */
591
+ export async function storeIdpTokensInSession(
592
+ sessionToken: string,
593
+ idpTokenData: Record<string, unknown>,
594
+ authProvider: string,
595
+ ): Promise<boolean> {
596
+ try {
597
+ const config = await getIDPClientConfig();
598
+ const appSlug = config.clientSlug || getAppSlug();
599
+ const baKey = `ba:${appSlug}:${sessionToken}`;
600
+
601
+ const baRaw = await getRedis().get(baKey).catch(() => null);
602
+ if (!baRaw) {
603
+ // No session to enrich. Do NOT create one as a fallback: a session
604
+ // conjured here would carry no Better Auth cookie, so it would be
605
+ // unreachable by the browser and invisible to every other consumer —
606
+ // failing as "silently signed out" rather than as an error.
607
+ console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', sessionToken.substring(0, 10));
608
+ return false;
609
+ }
610
+
611
+ const baData = JSON.parse(baRaw);
612
+ baData.idpTokens = idpTokenData;
613
+ await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
614
+
615
+ // Canonical store — the key getSession() reads, used by the refresh handler
616
+ // and token lifecycle. A failure here leaves the BA session correct and the
617
+ // canonical row missing, so it is warned and not fatal: the user is signed
618
+ // in, and refresh degrades rather than the sign-in failing outright.
619
+ try {
620
+ const { getSessionPrefix } = await import('../lib/app-slug');
621
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
622
+ await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
623
+ ...idpTokenData,
624
+ oauthProvider: authProvider,
625
+ }));
626
+ } catch (canonicalErr) {
627
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
628
+ }
629
+
630
+ return true;
631
+ } catch (err) {
632
+ console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
633
+ return false;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Create a production-ready GET handler for the auth catch-all route.
639
+ *
640
+ * Wraps better-auth's GET handler with:
641
+ * - OAuth state error recovery (redirects to login instead of error page)
642
+ * - IDP token exchange after successful OAuth callback
643
+ *
644
+ * Usage in host app:
645
+ * ```ts
646
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
647
+ * export const GET = createAuthGetHandler('/account-auth/login');
648
+ * export async function POST(req: Request) {
649
+ * const ba = await getBetterAuthHandler();
650
+ * return ba!.POST(req);
651
+ * }
652
+ * ```
653
+ */
654
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
655
+ return async function GET(request: Request): Promise<Response> {
656
+ // Card 138635: establish the request-scoped carrier for the provider's raw
657
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
658
+ // hook that sees the token) and the exchange below (which forwards it).
659
+ // Wrapping the whole handler is what makes "never persisted" achievable —
660
+ // the token exists only for the life of this request.
661
+ return idTokenScope.run({}, async () => {
662
+ const ba = await getBetterAuthHandler();
663
+ if (!ba) {
664
+ return new Response('Auth handler not configured', { status: 500 });
665
+ }
666
+
667
+ const response = await ba.GET(request);
668
+
669
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
670
+ if (response.status === 302) {
671
+ const location = response.headers.get('location') || '';
672
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
673
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
674
+ return Response.redirect(new URL(loginPath, request.url), 302);
675
+ }
676
+ }
677
+
678
+ // After successful OAuth callback: exchange Google identity for IDP tokens
679
+ const url = new URL(request.url);
680
+ if (url.pathname.includes('/callback/') && response.status === 302) {
681
+ try {
682
+ const auth = await getBetterAuthInstance();
683
+ if (auth?.api?.getSession) {
684
+ const setCookies = response.headers.getSetCookie?.() || [];
685
+ const cookieHeader = setCookies
686
+ .map((c: string) => c.split(';')[0])
687
+ .join('; ');
688
+
689
+ const headers = new Headers();
690
+ headers.set('cookie', cookieHeader);
691
+
692
+ const session = await auth.api.getSession({ headers });
693
+ if (session?.session?.token) {
694
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
695
+ await exchangeOAuthForIdpTokens(session.session.token);
696
+ } else {
697
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
698
+ }
699
+ }
700
+ } catch (err: any) {
701
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
702
+ }
703
+ }
704
+
705
+ return response;
706
+ });
707
+ };
708
+ }