@payez/next-mvp 4.1.12 → 4.1.13

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.
@@ -22,9 +22,6 @@ export interface BetterAuthSocialProvider {
22
22
  accessType?: 'offline' | 'online';
23
23
  hd?: string;
24
24
  }
25
- /**
26
- * Build Better Auth social providers from IDP config.
27
- */
28
25
  export declare function buildBetterAuthProviders(config: IDPClientConfig): Record<string, BetterAuthSocialProvider>;
29
26
  /**
30
27
  * Optional configuration for `createBetterAuthInstance`.
@@ -53,6 +53,8 @@ exports.getBetterAuthHandler = getBetterAuthHandler;
53
53
  exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
54
54
  exports.createAuthGetHandler = createAuthGetHandler;
55
55
  require("server-only");
56
+ const node_async_hooks_1 = require("node:async_hooks");
57
+ const jose_1 = require("jose"); // better-auth's own JWT dependency
56
58
  const better_auth_1 = require("better-auth");
57
59
  const next_js_1 = require("better-auth/next-js");
58
60
  const next_js_2 = require("better-auth/next-js");
@@ -63,6 +65,30 @@ const redis_1 = require("../lib/redis");
63
65
  /**
64
66
  * Build Better Auth social providers from IDP config.
65
67
  */
68
+ /**
69
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
70
+ *
71
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
72
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
73
+ * for the IDP to require a provider-signed id_token and verify it itself. This
74
+ * is the client half: capture the token Better Auth already receives, and
75
+ * forward it. Additive — the IDP ignores it until its gate flips.
76
+ *
77
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
78
+ * an id_token is credential material. Writing it to the session record would put
79
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
80
+ * and it would outlive the request that needed it. ALS scopes it to the single
81
+ * request, and it is gone when the request ends — nothing to expire, nothing to
82
+ * scrub, nothing to leak.
83
+ *
84
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
85
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
86
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
87
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
88
+ * function. Outside that scope the store is undefined and the body simply omits
89
+ * id_token, which is the correct additive behaviour.
90
+ */
91
+ const idTokenScope = new node_async_hooks_1.AsyncLocalStorage();
66
92
  function buildBetterAuthProviders(config) {
67
93
  const providers = {};
68
94
  for (const oauth of config.oauthProviders || []) {
@@ -92,6 +118,65 @@ function buildBetterAuthProviders(config) {
92
118
  accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
93
119
  hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
94
120
  };
121
+ /**
122
+ * CAPTURE THE RAW id_token — card 138635, client half.
123
+ *
124
+ * getUserInfo is the ONLY provider hook that receives the token set.
125
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
126
+ * signed JWT cannot be reconstructed from them. We need the raw token
127
+ * because the IDP must verify the signature ITSELF — a client-decoded
128
+ * claim is just another assertable field, which is the defect restated.
129
+ *
130
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
131
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
132
+ * returns our result verbatim, so everything the default did is now ours to
133
+ * do — including the `hd` hosted-domain restriction.
134
+ *
135
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
136
+ * above, so a client can restrict sign-in to a Google Workspace domain.
137
+ * Silently dropping it would let ANY Google account into a client that had
138
+ * restricted itself — a security regression introduced by a security fix.
139
+ * (Measured on 93: zero clients set it today. That is a reason to be
140
+ * careful, not a reason to skip it — it is configurable and unused, which
141
+ * is exactly the state in which a regression goes unnoticed.)
142
+ *
143
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
144
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
145
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
146
+ * default would return true unconditionally, so omitting it is equivalent
147
+ * by the library's own definition rather than by our judgement.
148
+ *
149
+ * A client that DOES set hd keeps the stock provider, keeps its domain
150
+ * restriction, and simply does not contribute an id_token yet. Losing a
151
+ * capability is an acceptable cost; silently losing a security control is
152
+ * not. (better-auth does not re-export the predicate through its public
153
+ * types, and reimplementing a security check is how two copies drift — so
154
+ * the honest move is to not need it.)
155
+ */
156
+ if (name === 'google' && !providers[name].hd) {
157
+ providers[name].getUserInfo = async (token) => {
158
+ if (!token?.idToken)
159
+ return null;
160
+ // The only new behaviour: hand the raw token to this request's scope.
161
+ const scope = idTokenScope.getStore();
162
+ if (scope)
163
+ scope.idToken = token.idToken;
164
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
165
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
166
+ // KEEP, never what we ACCEPT.
167
+ const claims = (0, jose_1.decodeJwt)(token.idToken);
168
+ return {
169
+ user: {
170
+ id: claims.sub,
171
+ name: claims.name,
172
+ email: claims.email,
173
+ image: claims.picture,
174
+ emailVerified: claims.email_verified,
175
+ },
176
+ data: claims,
177
+ };
178
+ };
179
+ }
95
180
  }
96
181
  return providers;
97
182
  }
@@ -297,6 +382,30 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
297
382
  name,
298
383
  image,
299
384
  client_id: config.clientSlug || String(config.clientId),
385
+ /**
386
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
387
+ *
388
+ * Every other field in this body is an ASSERTION. The endpoint mints an
389
+ * IDP bearer from them today with no cryptographic check of any kind,
390
+ * which is why it is a complete authentication bypass and why it has
391
+ * been publicly reachable for 488 days.
392
+ *
393
+ * This field is the only one the IDP can VERIFY: signature against
394
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
395
+ * from the verified claims. Additive — the IDP ignores it until its
396
+ * gate flips, so this cannot break a login on its own.
397
+ *
398
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
399
+ * decoded email_verified. A client-computed verification flag is a body
400
+ * field with a better story: an attacker posts email_verified:true and
401
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
402
+ * the fix is theatre.
403
+ *
404
+ * Absent when this runs outside the OAuth callback scope — the key is
405
+ * simply omitted rather than sent null, so "we never captured one" stays
406
+ * distinguishable from "the provider issued none".
407
+ */
408
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
300
409
  }),
301
410
  });
302
411
  const oauthResText = await oauthRes.text();
@@ -380,45 +489,52 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
380
489
  */
381
490
  function createAuthGetHandler(loginPath = '/account-auth/login') {
382
491
  return async function GET(request) {
383
- const ba = await getBetterAuthHandler();
384
- if (!ba) {
385
- return new Response('Auth handler not configured', { status: 500 });
386
- }
387
- const response = await ba.GET(request);
388
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
389
- if (response.status === 302) {
390
- const location = response.headers.get('location') || '';
391
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
392
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
393
- return Response.redirect(new URL(loginPath, request.url), 302);
492
+ // Card 138635: establish the request-scoped carrier for the provider's raw
493
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
494
+ // hook that sees the token) and the exchange below (which forwards it).
495
+ // Wrapping the whole handler is what makes "never persisted" achievable —
496
+ // the token exists only for the life of this request.
497
+ return idTokenScope.run({}, async () => {
498
+ const ba = await getBetterAuthHandler();
499
+ if (!ba) {
500
+ return new Response('Auth handler not configured', { status: 500 });
394
501
  }
395
- }
396
- // After successful OAuth callback: exchange Google identity for IDP tokens
397
- const url = new URL(request.url);
398
- if (url.pathname.includes('/callback/') && response.status === 302) {
399
- try {
400
- const auth = await getBetterAuthInstance();
401
- if (auth?.api?.getSession) {
402
- const setCookies = response.headers.getSetCookie?.() || [];
403
- const cookieHeader = setCookies
404
- .map((c) => c.split(';')[0])
405
- .join('; ');
406
- const headers = new Headers();
407
- headers.set('cookie', cookieHeader);
408
- const session = await auth.api.getSession({ headers });
409
- if (session?.session?.token) {
410
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
411
- await exchangeOAuthForIdpTokens(session.session.token);
412
- }
413
- else {
414
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
415
- }
502
+ const response = await ba.GET(request);
503
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
504
+ if (response.status === 302) {
505
+ const location = response.headers.get('location') || '';
506
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
507
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
508
+ return Response.redirect(new URL(loginPath, request.url), 302);
416
509
  }
417
510
  }
418
- catch (err) {
419
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
511
+ // After successful OAuth callback: exchange Google identity for IDP tokens
512
+ const url = new URL(request.url);
513
+ if (url.pathname.includes('/callback/') && response.status === 302) {
514
+ try {
515
+ const auth = await getBetterAuthInstance();
516
+ if (auth?.api?.getSession) {
517
+ const setCookies = response.headers.getSetCookie?.() || [];
518
+ const cookieHeader = setCookies
519
+ .map((c) => c.split(';')[0])
520
+ .join('; ');
521
+ const headers = new Headers();
522
+ headers.set('cookie', cookieHeader);
523
+ const session = await auth.api.getSession({ headers });
524
+ if (session?.session?.token) {
525
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
526
+ await exchangeOAuthForIdpTokens(session.session.token);
527
+ }
528
+ else {
529
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
530
+ }
531
+ }
532
+ }
533
+ catch (err) {
534
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
535
+ }
420
536
  }
421
- }
422
- return response;
537
+ return response;
538
+ });
423
539
  };
424
540
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.12",
3
+ "version": "4.1.13",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -891,4 +891,4 @@
891
891
  "typescript": "^5.4.5",
892
892
  "zustand": "^4.5.5"
893
893
  }
894
- }
894
+ }
@@ -1,450 +1,568 @@
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
- }
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
+
24
+ /**
25
+ * Better Auth social provider config shape.
26
+ */
27
+ export interface BetterAuthSocialProvider {
28
+ clientId: string;
29
+ clientSecret: string;
30
+ scope?: string[];
31
+ prompt?: string;
32
+ accessType?: 'offline' | 'online';
33
+ hd?: string;
34
+ }
35
+
36
+ /**
37
+ * Build Better Auth social providers from IDP config.
38
+ */
39
+ /**
40
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
41
+ *
42
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
43
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
44
+ * for the IDP to require a provider-signed id_token and verify it itself. This
45
+ * is the client half: capture the token Better Auth already receives, and
46
+ * forward it. Additive — the IDP ignores it until its gate flips.
47
+ *
48
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
49
+ * an id_token is credential material. Writing it to the session record would put
50
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
51
+ * and it would outlive the request that needed it. ALS scopes it to the single
52
+ * request, and it is gone when the request ends — nothing to expire, nothing to
53
+ * scrub, nothing to leak.
54
+ *
55
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
56
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
57
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
58
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
59
+ * function. Outside that scope the store is undefined and the body simply omits
60
+ * id_token, which is the correct additive behaviour.
61
+ */
62
+ const idTokenScope = new AsyncLocalStorage<{ idToken?: string }>();
63
+
64
+ export function buildBetterAuthProviders(
65
+ config: IDPClientConfig
66
+ ): Record<string, BetterAuthSocialProvider> {
67
+ const providers: Record<string, BetterAuthSocialProvider> = {};
68
+
69
+ for (const oauth of config.oauthProviders || []) {
70
+ if (!oauth.enabled) continue;
71
+ const name = oauth.provider.toLowerCase();
72
+ const additionalParams = oauth.additionalParams ?? {};
73
+ const rawPrompt = additionalParams.prompt;
74
+ const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
75
+ const rawHostedDomain = additionalParams.hd;
76
+
77
+ // Ensure profile scope is present for Google so avatar image is returned
78
+ const scopes = oauth.scopes?.split(' ') || [];
79
+ if (name === 'google' && !scopes.includes('profile')) {
80
+ scopes.push('profile');
81
+ }
82
+
83
+ providers[name] = {
84
+ clientId: oauth.clientId,
85
+ clientSecret: oauth.clientSecret,
86
+ scope: scopes.length > 0 ? scopes : undefined,
87
+ // Google is overly eager to reuse the last account unless we
88
+ // explicitly ask for account selection on each social login.
89
+ prompt: typeof rawPrompt === 'string'
90
+ ? rawPrompt
91
+ : name === 'google'
92
+ ? 'select_account'
93
+ : undefined,
94
+ accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
95
+ hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
96
+ };
97
+
98
+ /**
99
+ * CAPTURE THE RAW id_token — card 138635, client half.
100
+ *
101
+ * getUserInfo is the ONLY provider hook that receives the token set.
102
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
103
+ * signed JWT cannot be reconstructed from them. We need the raw token
104
+ * because the IDP must verify the signature ITSELF — a client-decoded
105
+ * claim is just another assertable field, which is the defect restated.
106
+ *
107
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
108
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
109
+ * returns our result verbatim, so everything the default did is now ours to
110
+ * do — including the `hd` hosted-domain restriction.
111
+ *
112
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
113
+ * above, so a client can restrict sign-in to a Google Workspace domain.
114
+ * Silently dropping it would let ANY Google account into a client that had
115
+ * restricted itself — a security regression introduced by a security fix.
116
+ * (Measured on 93: zero clients set it today. That is a reason to be
117
+ * careful, not a reason to skip it — it is configurable and unused, which
118
+ * is exactly the state in which a regression goes unnoticed.)
119
+ *
120
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
121
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
122
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
123
+ * default would return true unconditionally, so omitting it is equivalent
124
+ * by the library's own definition rather than by our judgement.
125
+ *
126
+ * A client that DOES set hd keeps the stock provider, keeps its domain
127
+ * restriction, and simply does not contribute an id_token yet. Losing a
128
+ * capability is an acceptable cost; silently losing a security control is
129
+ * not. (better-auth does not re-export the predicate through its public
130
+ * types, and reimplementing a security check is how two copies drift — so
131
+ * the honest move is to not need it.)
132
+ */
133
+ if (name === 'google' && !providers[name].hd) {
134
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
135
+ if (!token?.idToken) return null;
136
+
137
+ // The only new behaviour: hand the raw token to this request's scope.
138
+ const scope = idTokenScope.getStore();
139
+ if (scope) scope.idToken = token.idToken;
140
+
141
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
142
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
143
+ // KEEP, never what we ACCEPT.
144
+ const claims = decodeJwt(token.idToken) as Record<string, any>;
145
+ return {
146
+ user: {
147
+ id: claims.sub,
148
+ name: claims.name,
149
+ email: claims.email,
150
+ image: claims.picture,
151
+ emailVerified: claims.email_verified,
152
+ },
153
+ data: claims,
154
+ };
155
+ };
156
+ }
157
+ }
158
+
159
+ return providers;
160
+ }
161
+
162
+ /**
163
+ * Optional configuration for `createBetterAuthInstance`.
164
+ *
165
+ * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
166
+ * The host app supplies its own `sendMagicLink` callback — typically a
167
+ * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
168
+ * Omit the `magicLink` key entirely to skip the plugin; the consuming
169
+ * app will not have a magic-link flow.
170
+ */
171
+ export interface CreateBetterAuthInstanceOptions {
172
+ magicLink?: MagicLinkOptions;
173
+ }
174
+
175
+ /**
176
+ * Create Better Auth instance from IDP config.
177
+ *
178
+ * No database — runs in stateless mode with JWE cookie cache.
179
+ * Call after getIDPClientConfig() resolves.
180
+ */
181
+ export function createBetterAuthInstance(
182
+ idpConfig: IDPClientConfig,
183
+ opts: CreateBetterAuthInstanceOptions = {}
184
+ ) {
185
+ const appSlug = idpConfig.clientSlug || getAppSlug();
186
+
187
+ // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
188
+ // Must include /api/auth since that's where the catch-all route is mounted
189
+ const rawBaseURL = process.env.BETTER_AUTH_URL
190
+ || idpConfig.baseClientUrl
191
+ || `http://localhost:${process.env.PORT || '3000'}`;
192
+ const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
193
+
194
+ return betterAuth({
195
+ baseURL,
196
+ secret: idpConfig.authSecret as string,
197
+
198
+ socialProviders: buildBetterAuthProviders(idpConfig),
199
+
200
+ // Trust the app's own origin + any configured base URL
201
+ trustedOrigins: [
202
+ rawBaseURL,
203
+ baseURL,
204
+ ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
205
+ 'http://localhost:3000',
206
+ 'http://localhost:3400',
207
+ 'http://localhost:3600',
208
+ ],
209
+
210
+ // Redis-backed session storage via secondaryStorage
211
+ secondaryStorage: {
212
+ get: async (key: string) => {
213
+ return await getRedis().get(`ba:${appSlug}:${key}`);
214
+ },
215
+ set: async (key: string, value: string, ttl?: number) => {
216
+ const redis = getRedis();
217
+ if (ttl) {
218
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
219
+ } else {
220
+ await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
221
+ }
222
+ },
223
+ delete: async (key: string) => {
224
+ try {
225
+ await getRedis().del(`ba:${appSlug}:${key}`);
226
+ } catch { /* ignore */ }
227
+ },
228
+ },
229
+
230
+ session: {
231
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
232
+ //
233
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
234
+ // a decoded session in process memory for 5 minutes, bypassing Redis
235
+ // reads during the window. Consequence: if the canonical app session
236
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
237
+ // Better Auth's in-memory copy stayed alive — and callers got
238
+ // contradictory answers depending on whether they consulted Better
239
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
240
+ // visible in production traces: viability 200 + idp-token 200 +
241
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
242
+ //
243
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
244
+ // read on the secondary-storage path. Latency cost is acceptable
245
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
246
+ // workarounds in every consumer app — already shipped in idealvibe.online
247
+ // at b6a91f6.
248
+ cookieCache: {
249
+ enabled: false,
250
+ maxAge: 300,
251
+ refreshCache: false,
252
+ },
253
+ },
254
+
255
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
256
+ advanced: {
257
+ cookiePrefix: appSlug,
258
+ cookies: {
259
+ session_token: {
260
+ name: `${appSlug}.session-token`,
261
+ },
262
+ },
263
+ },
264
+
265
+ plugins: [
266
+ nextCookies(),
267
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
268
+ ],
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Better Auth is always enabled (NextAuth removed in 4.0).
274
+ */
275
+ export function isBetterAuthEnabled(): boolean {
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * Get Better Auth Next.js route handlers (GET, POST).
281
+ * Initializes Better Auth from IDP config on first call, caches the instance.
282
+ */
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
284
+ let cachedInstance: any = null;
285
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
+ let initPromise: Promise<any> | null = null;
287
+ let configuredOpts: CreateBetterAuthInstanceOptions = {};
288
+
289
+ // Expose for server-side session access (decode-session.ts)
290
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
291
+ export { cachedInstance as __betterAuthInstance };
292
+
293
+ /**
294
+ * Configure Better Auth instance options for this process.
295
+ *
296
+ * Must be called before the first auth request — before
297
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
298
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
299
+ * server bootstrap hook.
300
+ *
301
+ * Throws if called after the instance has already been resolved: options
302
+ * cannot be applied retroactively.
303
+ */
304
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
305
+ if (cachedInstance) {
306
+ throw new Error(
307
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
308
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
309
+ );
310
+ }
311
+ configuredOpts = opts;
312
+ }
313
+
314
+ export async function getBetterAuthInstance() {
315
+ if (cachedInstance) return cachedInstance;
316
+
317
+ if (!initPromise) {
318
+ initPromise = getIDPClientConfig(true).then(config => {
319
+ const instance = createBetterAuthInstance(config, configuredOpts);
320
+ cachedInstance = instance;
321
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
322
+ return instance;
323
+ });
324
+ }
325
+
326
+ return initPromise;
327
+ }
328
+
329
+ /**
330
+ * Get flag-gated auth handler for Next.js route.
331
+ *
332
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
333
+ * Otherwise returns null (auth disabled).
334
+ *
335
+ * Usage in host app route:
336
+ * ```ts
337
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
338
+ *
339
+ * export async function GET(req: Request) {
340
+ * const ba = await getBetterAuthHandler();
341
+ * if (ba) return ba.GET(req);
342
+ * }
343
+ * ```
344
+ */
345
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
346
+ if (!isBetterAuthEnabled()) return null;
347
+
348
+ const auth = await getBetterAuthInstance();
349
+ return toNextJsHandler(auth);
350
+ }
351
+
352
+ /**
353
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
354
+ *
355
+ * Call this from the OAuth callback route AFTER better-auth has processed the
356
+ * callback and created the session. Reads the session token from the Set-Cookie
357
+ * header of the response to find the BA Redis key.
358
+ *
359
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
360
+ */
361
+ export async function exchangeOAuthForIdpTokens(
362
+ sessionToken: string,
363
+ provider: string = 'google'
364
+ ): Promise<boolean> {
365
+ try {
366
+ const config = await getIDPClientConfig();
367
+ const appSlug = config.clientSlug || getAppSlug();
368
+ const baKey = `ba:${appSlug}:${sessionToken}`;
369
+
370
+ // Read the BA session from Redis
371
+ const baRaw = await getRedis().get(baKey).catch(() => null);
372
+ if (!baRaw) {
373
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
374
+ return false;
375
+ }
376
+
377
+ const baData = JSON.parse(baRaw);
378
+ const email = baData?.user?.email;
379
+ const name = baData?.user?.name;
380
+ const image = baData?.user?.image;
381
+ const baUserId = baData?.session?.userId || baData?.user?.id;
382
+
383
+ if (!email) {
384
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
385
+ return false;
386
+ }
387
+
388
+ // Call IDP oauth-callback
389
+ const idpUrl = process.env.IDP_URL || '';
390
+ if (!idpUrl) {
391
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
392
+ return false;
393
+ }
394
+
395
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
396
+
397
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify({
401
+ provider,
402
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
403
+ email,
404
+ name,
405
+ image,
406
+ client_id: config.clientSlug || String(config.clientId),
407
+ /**
408
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
409
+ *
410
+ * Every other field in this body is an ASSERTION. The endpoint mints an
411
+ * IDP bearer from them today with no cryptographic check of any kind,
412
+ * which is why it is a complete authentication bypass and why it has
413
+ * been publicly reachable for 488 days.
414
+ *
415
+ * This field is the only one the IDP can VERIFY: signature against
416
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
417
+ * from the verified claims. Additive — the IDP ignores it until its
418
+ * gate flips, so this cannot break a login on its own.
419
+ *
420
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
421
+ * decoded email_verified. A client-computed verification flag is a body
422
+ * field with a better story: an attacker posts email_verified:true and
423
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
424
+ * the fix is theatre.
425
+ *
426
+ * Absent when this runs outside the OAuth callback scope — the key is
427
+ * simply omitted rather than sent null, so "we never captured one" stays
428
+ * distinguishable from "the provider issued none".
429
+ */
430
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
431
+ }),
432
+ });
433
+
434
+ const oauthResText = await oauthRes.text();
435
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
436
+
437
+ if (!oauthRes.ok) {
438
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
439
+ return false;
440
+ }
441
+
442
+ let idpData: any;
443
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
444
+ const result = idpData?.data?.result || idpData?.data || idpData;
445
+
446
+ if (!result?.access_token) {
447
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
448
+ return false;
449
+ }
450
+
451
+ // Build IDP token data
452
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
453
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
454
+ const hasRefreshToken = !!result.refresh_token;
455
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
456
+ const idpTokenData = {
457
+ idpAccessToken: result.access_token,
458
+ idpRefreshToken: result.refresh_token,
459
+ idpAccessTokenExpires: result.expires_in
460
+ ? Date.now() + result.expires_in * 1000
461
+ : Date.now() + 15 * 60 * 1000,
462
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
463
+ email: result.user?.email || result.email || email,
464
+ name: result.user?.full_name || result.user?.name || result.name || name,
465
+ image: image,
466
+ roles: result.user?.roles || result.roles || [],
467
+ mfaVerified: !requiresTwoFactor,
468
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
469
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
470
+ };
471
+
472
+ // Store in BA Redis session (for decodeSession)
473
+ baData.idpTokens = idpTokenData;
474
+ await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
475
+
476
+ // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
477
+ // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
478
+ try {
479
+ const { getSessionPrefix } = await import('../lib/app-slug');
480
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
481
+ await getRedis().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
482
+ ...idpTokenData,
483
+ oauthProvider: provider,
484
+ }));
485
+ } catch (canonicalErr) {
486
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
487
+ }
488
+
489
+ console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
490
+ return true;
491
+ } catch (err) {
492
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
493
+ return false;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Create a production-ready GET handler for the auth catch-all route.
499
+ *
500
+ * Wraps better-auth's GET handler with:
501
+ * - OAuth state error recovery (redirects to login instead of error page)
502
+ * - IDP token exchange after successful OAuth callback
503
+ *
504
+ * Usage in host app:
505
+ * ```ts
506
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
507
+ * export const GET = createAuthGetHandler('/account-auth/login');
508
+ * export async function POST(req: Request) {
509
+ * const ba = await getBetterAuthHandler();
510
+ * return ba!.POST(req);
511
+ * }
512
+ * ```
513
+ */
514
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
515
+ return async function GET(request: Request): Promise<Response> {
516
+ // Card 138635: establish the request-scoped carrier for the provider's raw
517
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
518
+ // hook that sees the token) and the exchange below (which forwards it).
519
+ // Wrapping the whole handler is what makes "never persisted" achievable —
520
+ // the token exists only for the life of this request.
521
+ return idTokenScope.run({}, async () => {
522
+ const ba = await getBetterAuthHandler();
523
+ if (!ba) {
524
+ return new Response('Auth handler not configured', { status: 500 });
525
+ }
526
+
527
+ const response = await ba.GET(request);
528
+
529
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
530
+ if (response.status === 302) {
531
+ const location = response.headers.get('location') || '';
532
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
533
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
534
+ return Response.redirect(new URL(loginPath, request.url), 302);
535
+ }
536
+ }
537
+
538
+ // After successful OAuth callback: exchange Google identity for IDP tokens
539
+ const url = new URL(request.url);
540
+ if (url.pathname.includes('/callback/') && response.status === 302) {
541
+ try {
542
+ const auth = await getBetterAuthInstance();
543
+ if (auth?.api?.getSession) {
544
+ const setCookies = response.headers.getSetCookie?.() || [];
545
+ const cookieHeader = setCookies
546
+ .map((c: string) => c.split(';')[0])
547
+ .join('; ');
548
+
549
+ const headers = new Headers();
550
+ headers.set('cookie', cookieHeader);
551
+
552
+ const session = await auth.api.getSession({ headers });
553
+ if (session?.session?.token) {
554
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
555
+ await exchangeOAuthForIdpTokens(session.session.token);
556
+ } else {
557
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
558
+ }
559
+ }
560
+ } catch (err: any) {
561
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
562
+ }
563
+ }
564
+
565
+ return response;
566
+ });
567
+ };
568
+ }