@payez/next-mvp 4.1.11 → 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.
Files changed (92) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.d.ts +0 -3
  3. package/dist/auth/better-auth.js +164 -69
  4. package/dist/client/better-auth-client.d.ts +14 -2
  5. package/dist/lib/ensure-fresh-access-token.js +8 -42
  6. package/dist/lib/session-store.d.ts +0 -1
  7. package/dist/lib/session-store.js +29 -33
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/lib/token-lifecycle.d.ts +0 -2
  10. package/dist/lib/token-lifecycle.js +3 -21
  11. package/package.json +5 -4
  12. package/scripts/check-internal-url-usage.sh +73 -73
  13. package/src/api/index.ts +17 -17
  14. package/src/api-handlers/account/masked-info.ts +45 -45
  15. package/src/api-handlers/account/profile.ts +80 -80
  16. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  17. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  18. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  19. package/src/api-handlers/account/reset-password.ts +23 -23
  20. package/src/api-handlers/account/send-code.ts +76 -76
  21. package/src/api-handlers/account/validate-password.ts +118 -118
  22. package/src/api-handlers/admin/stats.ts +249 -249
  23. package/src/api-handlers/anon/preferences.ts +123 -123
  24. package/src/api-handlers/auth/jwks.ts +20 -20
  25. package/src/api-handlers/auth/login.ts +240 -240
  26. package/src/api-handlers/auth/refresh.ts +15 -38
  27. package/src/api-handlers/auth/validate.ts +43 -43
  28. package/src/api-handlers/session/viability.ts +142 -142
  29. package/src/auth/better-auth.ts +130 -29
  30. package/src/auth/unauthenticated-routes.ts +2 -2
  31. package/src/client/better-auth-client.ts +70 -70
  32. package/src/client/fetchWithSession.ts +21 -21
  33. package/src/client/index.ts +13 -13
  34. package/src/client/useAnonSession.ts +131 -131
  35. package/src/components/admin/VibeAdminContext.tsx +87 -87
  36. package/src/config/logging-config.ts +82 -82
  37. package/src/config/unauthenticated-routes.ts +19 -19
  38. package/src/edge/internal-api-url.ts +65 -65
  39. package/src/edge/middleware.ts +42 -42
  40. package/src/hooks/useProfile.ts +75 -75
  41. package/src/lib/anon-session.ts +213 -213
  42. package/src/lib/auth-secret.ts +137 -137
  43. package/src/lib/demo-mode.ts +17 -17
  44. package/src/lib/ensure-fresh-access-token.ts +8 -42
  45. package/src/lib/idp-client-config.ts +559 -559
  46. package/src/lib/idp-fetch.ts +126 -126
  47. package/src/lib/internal-api.ts +171 -171
  48. package/src/lib/jwt-decode-client.ts +45 -45
  49. package/src/lib/rate-limit-service.ts +9 -9
  50. package/src/lib/redis.ts +26 -26
  51. package/src/lib/refresh-token-validator.ts +64 -64
  52. package/src/lib/secret-validation.ts +8 -8
  53. package/src/lib/session-store.ts +692 -695
  54. package/src/lib/session.ts +34 -34
  55. package/src/lib/startup-init.ts +251 -251
  56. package/src/lib/token-expiry.ts +40 -40
  57. package/src/lib/token-lifecycle.ts +470 -497
  58. package/src/lib/types/api-responses.ts +336 -336
  59. package/src/middleware/auth-decision.ts +43 -43
  60. package/src/models/DecodedAccessToken.ts +16 -16
  61. package/src/models/SessionModel.ts +264 -264
  62. package/src/pages/admin-roles/index.ts +9 -9
  63. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  64. package/src/routes/account/index.ts +31 -31
  65. package/src/routes/account/masked-info.ts +36 -36
  66. package/src/routes/account/send-code.ts +39 -39
  67. package/src/routes/account/verify-email.ts +40 -40
  68. package/src/routes/account/verify-sms.ts +40 -40
  69. package/src/routes/auth/index.ts +22 -22
  70. package/src/routes/auth/refresh.ts +48 -48
  71. package/src/routes/auth/session.ts +166 -166
  72. package/src/routes/index.ts +17 -17
  73. package/src/routes/session/index.ts +7 -7
  74. package/src/routes/session/refresh-viability.ts +17 -17
  75. package/src/server/auth.ts +373 -373
  76. package/src/server/decode-session.ts +202 -202
  77. package/src/services/signalrActivityService.ts +258 -258
  78. package/src/theme/ThemeProvider.tsx +39 -39
  79. package/src/theme/default.ts +33 -33
  80. package/src/theme/index.ts +31 -31
  81. package/src/theme/types.ts +69 -69
  82. package/src/theme/useTheme.ts +57 -57
  83. package/src/theme/utils.ts +40 -40
  84. package/src/types/api.ts +12 -12
  85. package/src/types/auth.d.ts +23 -23
  86. package/src/types/auth.ts +22 -22
  87. package/src/types/logging.ts +11 -11
  88. package/src/types/recovery.ts +54 -54
  89. package/src/utils/api.ts +353 -353
  90. package/src/utils/circuitBreaker.ts +40 -40
  91. package/src/utils/error-message.ts +108 -108
  92. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -371,7 +371,7 @@ function createRefreshHandler(config) {
371
371
  const errorMessage = idpError.message || 'Token refresh failed';
372
372
  // CRITICAL: 401 means token is definitively invalid - always discard
373
373
  // This prevents redirect loops where viability check keeps returning canRefresh:true
374
- let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
374
+ const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
375
375
  const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
376
376
  const resolution = idpError.resolution || null;
377
377
  // ============================================================================
@@ -408,43 +408,20 @@ function createRefreshHandler(config) {
408
408
  console.error('[AUTH_REFRESH] Full IDP error response:', JSON.stringify(responseData, null, 2).substring(0, 1000));
409
409
  // If IDP signals to discard token, clear it from Redis
410
410
  if (discardToken) {
411
- // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
412
- // the current one. A concurrent refresh may have already rotated it — our 401
413
- // is then stale, and wiping here would silently log out a healthy session
414
- // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
415
- const postRefresh = await (0, session_store_1.getSession)(sessionToken);
416
- const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
417
- if (tokenUnchanged) {
418
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
419
- reason: 'IDP_DISCARD_TOKEN',
420
- errorCode,
421
- userId,
422
- sessionToken: sessionToken.substring(0, 8) + '...',
423
- hadRefreshToken: !!currentSession.idpRefreshToken,
424
- stack: new Error().stack
425
- });
426
- await (0, session_store_1.updateSession)(sessionToken, {
427
- idpRefreshToken: '',
428
- idpRefreshTokenExpires: undefined,
429
- refreshTokenClearedAt: Date.now(),
430
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
431
- });
432
- }
433
- else {
434
- // Concurrent refresh already rotated the token — our 401 is stale. Return the
435
- // freshly-rotated token directly so the loser succeeds without a retry
436
- // (race-fix follow-up #1).
437
- discardToken = false;
438
- console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
439
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
440
- return server_1.NextResponse.json({
441
- refreshed: true,
442
- reason: 'completed_by_concurrent_request',
443
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
444
- hasRefreshToken: !!postRefresh.idpRefreshToken,
445
- });
446
- }
447
- }
411
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
412
+ reason: 'IDP_DISCARD_TOKEN',
413
+ errorCode,
414
+ userId,
415
+ sessionToken: sessionToken.substring(0, 8) + '...',
416
+ hadRefreshToken: !!currentSession.idpRefreshToken,
417
+ stack: new Error().stack
418
+ });
419
+ await (0, session_store_1.updateSession)(sessionToken, {
420
+ idpRefreshToken: '',
421
+ idpRefreshTokenExpires: undefined,
422
+ refreshTokenClearedAt: Date.now(),
423
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
424
+ });
448
425
  }
449
426
  return server_1.NextResponse.json({
450
427
  error: errorMessage,
@@ -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");
@@ -60,10 +62,33 @@ const magic_link_1 = require("better-auth/plugins/magic-link");
60
62
  const idp_client_config_1 = require("../lib/idp-client-config");
61
63
  const app_slug_1 = require("../lib/app-slug");
62
64
  const redis_1 = require("../lib/redis");
63
- const session_store_1 = require("../lib/session-store");
64
65
  /**
65
66
  * Build Better Auth social providers from IDP config.
66
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();
67
92
  function buildBetterAuthProviders(config) {
68
93
  const providers = {};
69
94
  for (const oauth of config.oauthProviders || []) {
@@ -93,6 +118,65 @@ function buildBetterAuthProviders(config) {
93
118
  accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
94
119
  hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
95
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
+ }
96
180
  }
97
181
  return providers;
98
182
  }
@@ -126,24 +210,16 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
126
210
  // Redis-backed session storage via secondaryStorage
127
211
  secondaryStorage: {
128
212
  get: async (key) => {
129
- try {
130
- return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
131
- }
132
- catch {
133
- return null;
134
- }
213
+ return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
135
214
  },
136
215
  set: async (key, value, ttl) => {
137
- try {
138
- const redis = (0, redis_1.getRedis)();
139
- if (ttl) {
140
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
141
- }
142
- else {
143
- await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
144
- }
216
+ const redis = (0, redis_1.getRedis)();
217
+ if (ttl) {
218
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
219
+ }
220
+ else {
221
+ await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
145
222
  }
146
- catch { /* Redis unavailable — cookie cache still works */ }
147
223
  },
148
224
  delete: async (key) => {
149
225
  try {
@@ -306,6 +382,30 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
306
382
  name,
307
383
  image,
308
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 } : {}),
309
409
  }),
310
410
  });
311
411
  const oauthResText = await oauthRes.text();
@@ -327,20 +427,8 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
327
427
  return false;
328
428
  }
329
429
  // Build IDP token data
330
- // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
331
- // !hasRefreshToken was a reliable "2FA required" signal.
332
- //
333
- // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
334
- // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
335
- // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
336
- // bypassing the 2FA gate entirely.
337
- //
338
- // FIX: Always set mfaVerified=false on the initial OAuth exchange.
339
- // The tenant's require_2fa setting is enforced by the viability endpoint
340
- // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
341
- // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
342
- // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
343
- // IDP correctly withholds refresh_token until post-2FA.
430
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
431
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
344
432
  const hasRefreshToken = !!result.refresh_token;
345
433
  const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
346
434
  const idpTokenData = {
@@ -354,19 +442,19 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
354
442
  name: result.user?.full_name || result.user?.name || result.name || name,
355
443
  image: image,
356
444
  roles: result.user?.roles || result.roles || [],
357
- mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
445
+ mfaVerified: !requiresTwoFactor,
358
446
  idpClientId: result.client_id ? String(result.client_id) : undefined,
359
447
  merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
360
448
  };
361
449
  // Store in BA Redis session (for decodeSession)
362
450
  baData.idpTokens = idpTokenData;
363
- await (0, redis_1.getRedis)().setex(baKey, session_store_1.SESSION_TTL, JSON.stringify(baData));
451
+ await (0, redis_1.getRedis)().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
364
452
  // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
365
453
  // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
366
454
  try {
367
455
  const { getSessionPrefix } = await Promise.resolve().then(() => __importStar(require('../lib/app-slug')));
368
456
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
369
- await (0, redis_1.getRedis)().setex(canonicalKey, session_store_1.SESSION_TTL, JSON.stringify({
457
+ await (0, redis_1.getRedis)().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
370
458
  ...idpTokenData,
371
459
  oauthProvider: provider,
372
460
  }));
@@ -401,45 +489,52 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
401
489
  */
402
490
  function createAuthGetHandler(loginPath = '/account-auth/login') {
403
491
  return async function GET(request) {
404
- const ba = await getBetterAuthHandler();
405
- if (!ba) {
406
- return new Response('Auth handler not configured', { status: 500 });
407
- }
408
- const response = await ba.GET(request);
409
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
410
- if (response.status === 302) {
411
- const location = response.headers.get('location') || '';
412
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
413
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
414
- 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 });
415
501
  }
416
- }
417
- // After successful OAuth callback: exchange Google identity for IDP tokens
418
- const url = new URL(request.url);
419
- if (url.pathname.includes('/callback/') && response.status === 302) {
420
- try {
421
- const auth = await getBetterAuthInstance();
422
- if (auth?.api?.getSession) {
423
- const setCookies = response.headers.getSetCookie?.() || [];
424
- const cookieHeader = setCookies
425
- .map((c) => c.split(';')[0])
426
- .join('; ');
427
- const headers = new Headers();
428
- headers.set('cookie', cookieHeader);
429
- const session = await auth.api.getSession({ headers });
430
- if (session?.session?.token) {
431
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
432
- await exchangeOAuthForIdpTokens(session.session.token);
433
- }
434
- else {
435
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
436
- }
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);
437
509
  }
438
510
  }
439
- catch (err) {
440
- 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
+ }
441
536
  }
442
- }
443
- return response;
537
+ return response;
538
+ });
444
539
  };
445
540
  }
@@ -814,7 +814,13 @@ export declare const authClient: {
814
814
  isPending: boolean;
815
815
  isRefetching: boolean;
816
816
  error: import("@better-fetch/fetch").BetterFetchError | null;
817
- refetch: (queryParams?: {
817
+ refetch: (queryParams
818
+ /**
819
+ * NextAuth-compatible signOut wrapper.
820
+ *
821
+ * Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
822
+ */
823
+ ?: {
818
824
  query?: import("better-auth/types").SessionQueryParams;
819
825
  } | undefined) => Promise<void>;
820
826
  };
@@ -988,7 +994,13 @@ export declare const useSession: () => {
988
994
  isPending: boolean;
989
995
  isRefetching: boolean;
990
996
  error: import("@better-fetch/fetch").BetterFetchError | null;
991
- refetch: (queryParams?: {
997
+ refetch: (queryParams
998
+ /**
999
+ * NextAuth-compatible signOut wrapper.
1000
+ *
1001
+ * Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
1002
+ */
1003
+ ?: {
992
1004
  query?: import("better-auth/types").SessionQueryParams;
993
1005
  } | undefined) => Promise<void>;
994
1006
  }, signIn: {
@@ -158,16 +158,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
158
158
  if (isOAuthSession && session.mfaVerified && acrValue === '1') {
159
159
  acrValue = '2';
160
160
  }
161
- // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
162
- // `session` was read before we acquired the lock; a concurrent refresh that
163
- // completed while we waited rotates the token, so the pre-lock value is stale
164
- // and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
165
- // the round-trip is wasted). `latest` is the authoritative post-lock session.
166
- // Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
167
- // the identity context of THIS request, unchanged by a token rotation.
168
- const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
169
161
  const body = {
170
- refresh_token: sentRefreshToken,
162
+ refresh_token: session.idpRefreshToken,
171
163
  amr: authMethods,
172
164
  acr: acrValue,
173
165
  };
@@ -208,41 +200,15 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
208
200
  if (!idpResponse.ok) {
209
201
  const idpError = responseData?.error || {};
210
202
  const code = idpError.code || 'UNKNOWN_ERROR';
211
- let discardToken = idpResponse.status === 401 || idpError.discard_token === true;
203
+ const discardToken = idpResponse.status === 401 || idpError.discard_token === true;
212
204
  const retryable = idpResponse.status !== 401 && idpError.retryable === true;
213
205
  if (discardToken) {
214
- // Race-safe discard (CAS): only clear if the refresh token we used is STILL
215
- // current. A concurrent refresh may have already rotated it — our 401 is then
216
- // stale/expected, and wiping here would silently log out a healthy session
217
- // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
218
- // a concurrent rotation is NOT a terminal condition.
219
- const postRefresh = await (0, session_store_1.getSession)(sessionToken);
220
- // CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
221
- // the post-lock value), not the pre-lock snapshot — otherwise a token rotated
222
- // between pre-lock read and our send would be misjudged.
223
- const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
224
- if (tokenUnchanged) {
225
- await (0, session_store_1.updateSession)(sessionToken, {
226
- idpRefreshToken: '',
227
- idpRefreshTokenExpires: undefined,
228
- refreshTokenClearedAt: Date.now(),
229
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
230
- });
231
- }
232
- else {
233
- // Concurrent refresh already rotated the token — our 401 is stale. Return the
234
- // freshly-rotated token directly so the loser succeeds without a retry round-trip
235
- // (race-fix follow-up #1).
236
- discardToken = false;
237
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
238
- return {
239
- ok: true,
240
- accessToken: postRefresh.idpAccessToken,
241
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
242
- refreshed: true,
243
- };
244
- }
245
- }
206
+ await (0, session_store_1.updateSession)(sessionToken, {
207
+ idpRefreshToken: '',
208
+ idpRefreshTokenExpires: undefined,
209
+ refreshTokenClearedAt: Date.now(),
210
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
211
+ });
246
212
  }
247
213
  return {
248
214
  ok: false,
@@ -9,7 +9,6 @@
9
9
  */
10
10
  import { SessionData } from '../models/SessionModel';
11
11
  export type { SessionData } from '../models/SessionModel';
12
- export declare const SESSION_TTL: number;
13
12
  export interface RefreshLockInfo {
14
13
  sessionToken: string;
15
14
  acquiredAt: number;
@@ -12,7 +12,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.SESSION_TTL = void 0;
16
15
  exports.generateSessionToken = generateSessionToken;
17
16
  exports.createSession = createSession;
18
17
  exports.getSession = getSession;
@@ -47,10 +46,7 @@ const getSessionVersionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}ve
47
46
  // Better Auth uses a different key format: ba:{appSlug}:{token}
48
47
  const getBetterAuthSessionKey = (token, appSlug) => `ba:${appSlug || 'app'}:${token}`;
49
48
  const REFRESH_LOCK_TTL = 60; // 60 seconds
50
- // 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
51
- // of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
52
- // two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
53
- exports.SESSION_TTL = 3 * 24 * 60 * 60;
49
+ const SESSION_TTL = 3 * 24 * 60 * 60; // 3 days in seconds (matches refresh token lifetime)
54
50
  /**
55
51
  * Generates a new session token.
56
52
  * @returns A new session token string.
@@ -70,8 +66,8 @@ async function createSession(data) {
70
66
  const versionKey = getSessionVersionKey(sessionToken);
71
67
  try {
72
68
  await redis_1.default.multi()
73
- .setex(key, exports.SESSION_TTL, JSON.stringify(data))
74
- .setex(versionKey, exports.SESSION_TTL, '1')
69
+ .setex(key, SESSION_TTL, JSON.stringify(data))
70
+ .setex(versionKey, SESSION_TTL, '1')
75
71
  .exec();
76
72
  }
77
73
  catch (error) {
@@ -152,7 +148,7 @@ async function getBetterAuthSession(sessionToken, appSlug) {
152
148
  */
153
149
  async function touchSession(token) {
154
150
  const key = getSessionKey(token);
155
- await redis_1.default.expire(key, exports.SESSION_TTL);
151
+ await redis_1.default.expire(key, SESSION_TTL);
156
152
  }
157
153
  /**
158
154
  * Retrieves a session along with a version identifier for optimistic locking.
@@ -218,9 +214,9 @@ async function setSession(sessionToken, data) {
218
214
  const key = getSessionKey(sessionToken);
219
215
  const versionKey = getSessionVersionKey(sessionToken);
220
216
  await redis_1.default.multi()
221
- .setex(key, exports.SESSION_TTL, JSON.stringify(data))
217
+ .setex(key, SESSION_TTL, JSON.stringify(data))
222
218
  .incr(versionKey)
223
- .expire(versionKey, exports.SESSION_TTL)
219
+ .expire(versionKey, SESSION_TTL)
224
220
  .exec();
225
221
  }
226
222
  /**
@@ -258,9 +254,9 @@ async function updateSession(sessionToken, updates) {
258
254
  const updatedSession = { ...currentSession, ...updates };
259
255
  // Write the entire updated session back to Redis with version increment
260
256
  await redis_1.default.multi()
261
- .setex(key, exports.SESSION_TTL, JSON.stringify(updatedSession))
257
+ .setex(key, SESSION_TTL, JSON.stringify(updatedSession))
262
258
  .incr(versionKey)
263
- .expire(versionKey, exports.SESSION_TTL)
259
+ .expire(versionKey, SESSION_TTL)
264
260
  .exec();
265
261
  return updatedSession;
266
262
  }
@@ -489,27 +485,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
489
485
  const lockKey = getRefreshLockKey(sessionToken);
490
486
  try {
491
487
  // Lua script for atomic lock validation and release
492
- const luaScript = `
493
- local lockKey = KEYS[1]
494
- local expectedRequestId = ARGV[1]
495
- local expectedVersion = ARGV[2]
496
-
497
- local lockData = redis.call('GET', lockKey)
498
- if not lockData then
499
- return 0 -- Lock doesn't exist
500
- end
501
-
502
- local lockInfo = cjson.decode(lockData)
503
- if lockInfo.acquiredBy == expectedRequestId then
504
- if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
505
- redis.call('DEL', lockKey)
506
- return 1 -- Successfully released
507
- else
508
- return -2 -- Version mismatch
509
- end
510
- else
511
- return -1 -- Wrong owner
512
- end
488
+ const luaScript = `
489
+ local lockKey = KEYS[1]
490
+ local expectedRequestId = ARGV[1]
491
+ local expectedVersion = ARGV[2]
492
+
493
+ local lockData = redis.call('GET', lockKey)
494
+ if not lockData then
495
+ return 0 -- Lock doesn't exist
496
+ end
497
+
498
+ local lockInfo = cjson.decode(lockData)
499
+ if lockInfo.acquiredBy == expectedRequestId then
500
+ if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
501
+ redis.call('DEL', lockKey)
502
+ return 1 -- Successfully released
503
+ else
504
+ return -2 -- Version mismatch
505
+ end
506
+ else
507
+ return -1 -- Wrong owner
508
+ end
513
509
  `;
514
510
  const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
515
511
  if (result === 1) {