@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
@@ -51,8 +51,11 @@ exports.configureBetterAuth = configureBetterAuth;
51
51
  exports.getBetterAuthInstance = getBetterAuthInstance;
52
52
  exports.getBetterAuthHandler = getBetterAuthHandler;
53
53
  exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
54
+ exports.storeIdpTokensInSession = storeIdpTokensInSession;
54
55
  exports.createAuthGetHandler = createAuthGetHandler;
55
56
  require("server-only");
57
+ const node_async_hooks_1 = require("node:async_hooks");
58
+ const jose_1 = require("jose"); // better-auth's own JWT dependency
56
59
  const better_auth_1 = require("better-auth");
57
60
  const next_js_1 = require("better-auth/next-js");
58
61
  const next_js_2 = require("better-auth/next-js");
@@ -64,6 +67,30 @@ const session_store_1 = require("../lib/session-store");
64
67
  /**
65
68
  * Build Better Auth social providers from IDP config.
66
69
  */
70
+ /**
71
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
72
+ *
73
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
74
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
75
+ * for the IDP to require a provider-signed id_token and verify it itself. This
76
+ * is the client half: capture the token Better Auth already receives, and
77
+ * forward it. Additive — the IDP ignores it until its gate flips.
78
+ *
79
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
80
+ * an id_token is credential material. Writing it to the session record would put
81
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
82
+ * and it would outlive the request that needed it. ALS scopes it to the single
83
+ * request, and it is gone when the request ends — nothing to expire, nothing to
84
+ * scrub, nothing to leak.
85
+ *
86
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
87
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
88
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
89
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
90
+ * function. Outside that scope the store is undefined and the body simply omits
91
+ * id_token, which is the correct additive behaviour.
92
+ */
93
+ const idTokenScope = new node_async_hooks_1.AsyncLocalStorage();
67
94
  function buildBetterAuthProviders(config) {
68
95
  const providers = {};
69
96
  for (const oauth of config.oauthProviders || []) {
@@ -93,6 +120,65 @@ function buildBetterAuthProviders(config) {
93
120
  accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
94
121
  hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
95
122
  };
123
+ /**
124
+ * CAPTURE THE RAW id_token — card 138635, client half.
125
+ *
126
+ * getUserInfo is the ONLY provider hook that receives the token set.
127
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
128
+ * signed JWT cannot be reconstructed from them. We need the raw token
129
+ * because the IDP must verify the signature ITSELF — a client-decoded
130
+ * claim is just another assertable field, which is the defect restated.
131
+ *
132
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
133
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
134
+ * returns our result verbatim, so everything the default did is now ours to
135
+ * do — including the `hd` hosted-domain restriction.
136
+ *
137
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
138
+ * above, so a client can restrict sign-in to a Google Workspace domain.
139
+ * Silently dropping it would let ANY Google account into a client that had
140
+ * restricted itself — a security regression introduced by a security fix.
141
+ * (Measured on 93: zero clients set it today. That is a reason to be
142
+ * careful, not a reason to skip it — it is configurable and unused, which
143
+ * is exactly the state in which a regression goes unnoticed.)
144
+ *
145
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
146
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
147
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
148
+ * default would return true unconditionally, so omitting it is equivalent
149
+ * by the library's own definition rather than by our judgement.
150
+ *
151
+ * A client that DOES set hd keeps the stock provider, keeps its domain
152
+ * restriction, and simply does not contribute an id_token yet. Losing a
153
+ * capability is an acceptable cost; silently losing a security control is
154
+ * not. (better-auth does not re-export the predicate through its public
155
+ * types, and reimplementing a security check is how two copies drift — so
156
+ * the honest move is to not need it.)
157
+ */
158
+ if (name === 'google' && !providers[name].hd) {
159
+ providers[name].getUserInfo = async (token) => {
160
+ if (!token?.idToken)
161
+ return null;
162
+ // The only new behaviour: hand the raw token to this request's scope.
163
+ const scope = idTokenScope.getStore();
164
+ if (scope)
165
+ scope.idToken = token.idToken;
166
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
167
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
168
+ // KEEP, never what we ACCEPT.
169
+ const claims = (0, jose_1.decodeJwt)(token.idToken);
170
+ return {
171
+ user: {
172
+ id: claims.sub,
173
+ name: claims.name,
174
+ email: claims.email,
175
+ image: claims.picture,
176
+ emailVerified: claims.email_verified,
177
+ },
178
+ data: claims,
179
+ };
180
+ };
181
+ }
96
182
  }
97
183
  return providers;
98
184
  }
@@ -188,6 +274,9 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
188
274
  plugins: [
189
275
  (0, next_js_1.nextCookies)(),
190
276
  ...(opts.magicLink ? [(0, magic_link_1.magicLink)(opts.magicLink)] : []),
277
+ // App-supplied plugins last: they may depend on the built-ins above, and
278
+ // ordering is BA's own concern once they are in the array.
279
+ ...(opts.plugins ?? []),
191
280
  ],
192
281
  });
193
282
  }
@@ -306,6 +395,30 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
306
395
  name,
307
396
  image,
308
397
  client_id: config.clientSlug || String(config.clientId),
398
+ /**
399
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
400
+ *
401
+ * Every other field in this body is an ASSERTION. The endpoint mints an
402
+ * IDP bearer from them today with no cryptographic check of any kind,
403
+ * which is why it is a complete authentication bypass and why it has
404
+ * been publicly reachable for 488 days.
405
+ *
406
+ * This field is the only one the IDP can VERIFY: signature against
407
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
408
+ * from the verified claims. Additive — the IDP ignores it until its
409
+ * gate flips, so this cannot break a login on its own.
410
+ *
411
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
412
+ * decoded email_verified. A client-computed verification flag is a body
413
+ * field with a better story: an attacker posts email_verified:true and
414
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
415
+ * the fix is theatre.
416
+ *
417
+ * Absent when this runs outside the OAuth callback scope — the key is
418
+ * simply omitted rather than sent null, so "we never captured one" stays
419
+ * distinguishable from "the provider issued none".
420
+ */
421
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
309
422
  }),
310
423
  });
311
424
  const oauthResText = await oauthRes.text();
@@ -327,8 +440,20 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
327
440
  return false;
328
441
  }
329
442
  // Build IDP token data
330
- // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
331
- // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
443
+ // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
444
+ // !hasRefreshToken was a reliable "2FA required" signal.
445
+ //
446
+ // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
447
+ // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
448
+ // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
449
+ // bypassing the 2FA gate entirely.
450
+ //
451
+ // FIX: Always set mfaVerified=false on the initial OAuth exchange.
452
+ // The tenant's require_2fa setting is enforced by the viability endpoint
453
+ // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
454
+ // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
455
+ // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
456
+ // IDP correctly withholds refresh_token until post-2FA.
332
457
  const hasRefreshToken = !!result.refresh_token;
333
458
  const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
334
459
  const idpTokenData = {
@@ -342,31 +467,125 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
342
467
  name: result.user?.full_name || result.user?.name || result.name || name,
343
468
  image: image,
344
469
  roles: result.user?.roles || result.roles || [],
345
- mfaVerified: !requiresTwoFactor,
470
+ mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
471
+ // Cards 141688/141741 — RELAYED, NOT DERIVED.
472
+ //
473
+ // The IDP already decides this (ExternalAuthController:943 and :2024,
474
+ // WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
475
+ // confirmed method exists, null = fully authenticated. It was emitted
476
+ // here and dropped on this line — `idpTokenData` is built field-by-field,
477
+ // so anything not named is silently discarded. Both downstream consumers
478
+ // (the middleware gate and the verify-code page) then reasoned in its
479
+ // absence and sent zero-method users to a page they cannot act on.
480
+ //
481
+ // WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
482
+ // BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
483
+ // `result.nextStep` here would be `undefined` forever and fail silently —
484
+ // the same camel/snake break `requires_two_factor` already cost us once.
485
+ //
486
+ // Do NOT recompute this from a method query. The IDP owns the fact; it is
487
+ // already usable-aware (confirmed and not revoked, per DotNetPert), which
488
+ // is the distinction that decides correctness: a revoked-TOTP user has
489
+ // count>0 but usable=0, and deriving it locally would let through exactly
490
+ // the users we must challenge.
491
+ // No `?? null`: if the response has no `next_step` at all (older IDP
492
+ // build, or a path that does not set it), that is UNKNOWN, not "fully
493
+ // authenticated". Defaulting it to null here would manufacture a
494
+ // clean-bill-of-health the IDP never gave and fail open at the gate.
495
+ nextStep: result.next_step,
346
496
  idpClientId: result.client_id ? String(result.client_id) : undefined,
347
497
  merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
348
498
  };
349
- // Store in BA Redis session (for decodeSession)
499
+ // Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
500
+ const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
501
+ if (stored)
502
+ console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
503
+ return stored;
504
+ }
505
+ catch (err) {
506
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
507
+ return false;
508
+ }
509
+ }
510
+ /**
511
+ * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
512
+ * function never acquires tokens and never decides who the caller is.
513
+ *
514
+ * ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
515
+ *
516
+ * `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
517
+ * POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
518
+ * the second half is reusable, and the first half is actively dangerous to
519
+ * reuse:
520
+ *
521
+ * oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
522
+ * cryptographic verification of any kind (card 138635).
523
+ *
524
+ * Passkey sign-in already holds something strictly better — a grant issued by
525
+ * `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
526
+ * signature against a registered credential. Routing that through
527
+ * oauth-callback to obtain a session would DOWNGRADE a cryptographically
528
+ * verified assertion into an unverified one, make passkey sign-in weaker than
529
+ * its own ceremony, and inherit 138635 wholesale.
530
+ *
531
+ * That is not hypothetical: "do what the OAuth path does" is the obvious
532
+ * implementation and it is the wrong one. Splitting acquisition from storage is
533
+ * what makes the wrong one hard to write by accident.
534
+ *
535
+ * ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
536
+ *
537
+ * A Better Auth session must ALREADY EXIST for `sessionToken`. This function
538
+ * enriches; it does not create. Better Auth owns session creation and cookie
539
+ * signing, and nothing in this package should replicate either — the cookie is
540
+ * `{token}.{base64(hmac)}` with standard padding, signed with the runtime
541
+ * `authSecret` from Redis, and hand-rolling that produces a session which
542
+ * decodes but does not authenticate.
543
+ *
544
+ * @param sessionToken the Better Auth session token (from its Set-Cookie)
545
+ * @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
546
+ * verified route. Built field-by-field by the caller so an
547
+ * unnamed field cannot ride along silently.
548
+ * @param authProvider provenance label recorded on the canonical row. Written
549
+ * to the historical `oauthProvider` key, which predates
550
+ * non-OAuth callers; the NAME is legacy, the VALUE should
551
+ * state what actually authenticated ('passkey', 'google').
552
+ */
553
+ async function storeIdpTokensInSession(sessionToken, idpTokenData, authProvider) {
554
+ try {
555
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
556
+ const appSlug = config.clientSlug || (0, app_slug_1.getAppSlug)();
557
+ const baKey = `ba:${appSlug}:${sessionToken}`;
558
+ const baRaw = await (0, redis_1.getRedis)().get(baKey).catch(() => null);
559
+ if (!baRaw) {
560
+ // No session to enrich. Do NOT create one as a fallback: a session
561
+ // conjured here would carry no Better Auth cookie, so it would be
562
+ // unreachable by the browser and invisible to every other consumer —
563
+ // failing as "silently signed out" rather than as an error.
564
+ console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', sessionToken.substring(0, 10));
565
+ return false;
566
+ }
567
+ const baData = JSON.parse(baRaw);
350
568
  baData.idpTokens = idpTokenData;
351
569
  await (0, redis_1.getRedis)().setex(baKey, session_store_1.SESSION_TTL, JSON.stringify(baData));
352
- // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
353
- // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
570
+ // Canonical store — the key getSession() reads, used by the refresh handler
571
+ // and token lifecycle. A failure here leaves the BA session correct and the
572
+ // canonical row missing, so it is warned and not fatal: the user is signed
573
+ // in, and refresh degrades rather than the sign-in failing outright.
354
574
  try {
355
575
  const { getSessionPrefix } = await Promise.resolve().then(() => __importStar(require('../lib/app-slug')));
356
576
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
357
577
  await (0, redis_1.getRedis)().setex(canonicalKey, session_store_1.SESSION_TTL, JSON.stringify({
358
578
  ...idpTokenData,
359
- oauthProvider: provider,
579
+ oauthProvider: authProvider,
360
580
  }));
361
581
  }
362
582
  catch (canonicalErr) {
363
583
  console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
364
584
  }
365
- console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
366
585
  return true;
367
586
  }
368
587
  catch (err) {
369
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
588
+ console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
370
589
  return false;
371
590
  }
372
591
  }
@@ -389,45 +608,52 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
389
608
  */
390
609
  function createAuthGetHandler(loginPath = '/account-auth/login') {
391
610
  return async function GET(request) {
392
- const ba = await getBetterAuthHandler();
393
- if (!ba) {
394
- return new Response('Auth handler not configured', { status: 500 });
395
- }
396
- const response = await ba.GET(request);
397
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
398
- if (response.status === 302) {
399
- const location = response.headers.get('location') || '';
400
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
401
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
402
- return Response.redirect(new URL(loginPath, request.url), 302);
611
+ // Card 138635: establish the request-scoped carrier for the provider's raw
612
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
613
+ // hook that sees the token) and the exchange below (which forwards it).
614
+ // Wrapping the whole handler is what makes "never persisted" achievable —
615
+ // the token exists only for the life of this request.
616
+ return idTokenScope.run({}, async () => {
617
+ const ba = await getBetterAuthHandler();
618
+ if (!ba) {
619
+ return new Response('Auth handler not configured', { status: 500 });
403
620
  }
404
- }
405
- // After successful OAuth callback: exchange Google identity for IDP tokens
406
- const url = new URL(request.url);
407
- if (url.pathname.includes('/callback/') && response.status === 302) {
408
- try {
409
- const auth = await getBetterAuthInstance();
410
- if (auth?.api?.getSession) {
411
- const setCookies = response.headers.getSetCookie?.() || [];
412
- const cookieHeader = setCookies
413
- .map((c) => c.split(';')[0])
414
- .join('; ');
415
- const headers = new Headers();
416
- headers.set('cookie', cookieHeader);
417
- const session = await auth.api.getSession({ headers });
418
- if (session?.session?.token) {
419
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
420
- await exchangeOAuthForIdpTokens(session.session.token);
421
- }
422
- else {
423
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
424
- }
621
+ const response = await ba.GET(request);
622
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
623
+ if (response.status === 302) {
624
+ const location = response.headers.get('location') || '';
625
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
626
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
627
+ return Response.redirect(new URL(loginPath, request.url), 302);
425
628
  }
426
629
  }
427
- catch (err) {
428
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
630
+ // After successful OAuth callback: exchange Google identity for IDP tokens
631
+ const url = new URL(request.url);
632
+ if (url.pathname.includes('/callback/') && response.status === 302) {
633
+ try {
634
+ const auth = await getBetterAuthInstance();
635
+ if (auth?.api?.getSession) {
636
+ const setCookies = response.headers.getSetCookie?.() || [];
637
+ const cookieHeader = setCookies
638
+ .map((c) => c.split(';')[0])
639
+ .join('; ');
640
+ const headers = new Headers();
641
+ headers.set('cookie', cookieHeader);
642
+ const session = await auth.api.getSession({ headers });
643
+ if (session?.session?.token) {
644
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
645
+ await exchangeOAuthForIdpTokens(session.session.token);
646
+ }
647
+ else {
648
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
649
+ }
650
+ }
651
+ }
652
+ catch (err) {
653
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
654
+ }
429
655
  }
430
- }
431
- return response;
656
+ return response;
657
+ });
432
658
  };
433
659
  }
@@ -11,7 +11,7 @@
11
11
  * @version 1.0.0
12
12
  * @since auth-refactor-2026-01
13
13
  */
14
- import type { IdpLoginResponse, IdpOAuthCallbackResponse, IdpRefreshResponse, LoginCredentials } from '../types/auth-types';
14
+ import type { IdpLoginResponse, IdpRefreshResponse, LoginCredentials } from '../types/auth-types';
15
15
  /**
16
16
  * Get IDP base URL. Throws if not configured.
17
17
  */
@@ -32,30 +32,21 @@ export declare function idpLogin(credentials: LoginCredentials, clientHeaders?:
32
32
  userAgent?: string;
33
33
  }): Promise<IdpLoginResponse>;
34
34
  /**
35
- * Register/authenticate OAuth user with IDP.
35
+ * idpOAuthCallback REMOVED 2026-08-05 (NextPert, BAPert 20168 / QAPert 20166).
36
36
  *
37
- * Called after OAuth provider (Google, etc.) redirects back.
38
- * Creates or retrieves IDP user and returns IDP tokens.
37
+ * It POSTed to /api/ExternalAuth/oauth-callback with provider, email, name,
38
+ * tokens and client_id — ALL ASSERTIONS, no id_token, nothing verifiable. After
39
+ * the 170003 fix (13436d56e) that call can only ever return 401.
39
40
  *
40
- * @param oauthData - Data from OAuth provider
41
- * @returns IDP response with tokens and user info
42
- */
43
- export declare function idpOAuthCallback(oauthData: {
44
- provider: string;
45
- providerAccountId: string;
46
- email: string;
47
- name?: string;
48
- image?: string;
49
- accessToken?: string;
50
- refreshToken?: string;
51
- expiresAt?: number;
52
- }): Promise<IdpOAuthCallbackResponse>;
53
- /**
54
- * Refresh an expired access token using the refresh token.
41
+ * It was a SECOND caller of the same endpoint, and only the first
42
+ * (better-auth.ts, card 138635) was hardened — the same duplicated-integration
43
+ * trap the viability code documents ("adding the relay to just one would leave
44
+ * an app that mounts THIS one still blind").
55
45
  *
56
- * @param refreshToken - The refresh token from previous login
57
- * @param mfaContext - MFA context to preserve across refresh
58
- * @returns New tokens or error
46
+ * It had no call sites and was not reachable through the package exports map
47
+ * (no ./auth/utils key, no wildcard), so removing it breaks nothing. Deleted
48
+ * rather than fixed, because an unreachable function whose only outcome is a
49
+ * 401 that LOOKS like a security success is a trap for whoever finds it next.
59
50
  */
60
51
  export declare function idpRefreshToken(refreshToken: string, mfaContext?: {
61
52
  amr?: string[];
@@ -16,7 +16,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.getIdpUrl = getIdpUrl;
17
17
  exports.getClientId = getClientId;
18
18
  exports.idpLogin = idpLogin;
19
- exports.idpOAuthCallback = idpOAuthCallback;
20
19
  exports.idpRefreshToken = idpRefreshToken;
21
20
  exports.idpVerify2FA = idpVerify2FA;
22
21
  exports.idpSend2FACode = idpSend2FACode;
@@ -133,90 +132,21 @@ function getLoginErrorMessage(status, responseData) {
133
132
  // OAUTH CALLBACK
134
133
  // ============================================================================
135
134
  /**
136
- * Register/authenticate OAuth user with IDP.
135
+ * idpOAuthCallback REMOVED 2026-08-05 (NextPert, BAPert 20168 / QAPert 20166).
137
136
  *
138
- * Called after OAuth provider (Google, etc.) redirects back.
139
- * Creates or retrieves IDP user and returns IDP tokens.
137
+ * It POSTed to /api/ExternalAuth/oauth-callback with provider, email, name,
138
+ * tokens and client_id — ALL ASSERTIONS, no id_token, nothing verifiable. After
139
+ * the 170003 fix (13436d56e) that call can only ever return 401.
140
140
  *
141
- * @param oauthData - Data from OAuth provider
142
- * @returns IDP response with tokens and user info
143
- */
144
- async function idpOAuthCallback(oauthData) {
145
- const idpUrl = getIdpUrl();
146
- const clientId = getClientId();
147
- try {
148
- const response = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
149
- method: 'POST',
150
- headers: {
151
- 'Content-Type': 'application/json',
152
- 'X-Client-Id': clientId,
153
- },
154
- body: JSON.stringify({
155
- provider: oauthData.provider,
156
- provider_account_id: oauthData.providerAccountId,
157
- email: oauthData.email,
158
- name: oauthData.name || '',
159
- image: oauthData.image || '',
160
- access_token: oauthData.accessToken || '',
161
- refresh_token: oauthData.refreshToken || '',
162
- expires_at: oauthData.expiresAt || 0,
163
- client_id: clientId,
164
- }),
165
- });
166
- if (!response.ok) {
167
- const errorText = await response.text().catch(() => 'Unknown error');
168
- console.error('[IDP_CLIENT] OAuth callback failed:', response.status, errorText);
169
- return {
170
- success: false,
171
- error: {
172
- code: `HTTP_${response.status}`,
173
- message: 'OAuth registration failed',
174
- },
175
- };
176
- }
177
- const data = await response.json();
178
- const responseData = data.data || data;
179
- // Normalize snake_case to camelCase
180
- return {
181
- success: responseData.success !== false,
182
- data: responseData.success !== false
183
- ? {
184
- accessToken: responseData.accessToken || responseData.access_token,
185
- refreshToken: responseData.refreshToken || responseData.refresh_token,
186
- isNewUser: responseData.isNewUser ?? responseData.is_new_user ?? false,
187
- user: responseData.user
188
- ? {
189
- userId: responseData.user.userId || responseData.user.user_id,
190
- email: responseData.user.email || responseData.user.Email,
191
- fullName: responseData.user.fullName || responseData.user.full_name || responseData.user.name,
192
- roles: responseData.user.roles || [],
193
- }
194
- : undefined,
195
- }
196
- : undefined,
197
- error: responseData.error,
198
- };
199
- }
200
- catch (error) {
201
- console.error('[IDP_CLIENT] OAuth callback request failed:', error);
202
- return {
203
- success: false,
204
- error: {
205
- code: 'NETWORK_ERROR',
206
- message: 'Failed to connect to authentication service',
207
- },
208
- };
209
- }
210
- }
211
- // ============================================================================
212
- // TOKEN REFRESH
213
- // ============================================================================
214
- /**
215
- * Refresh an expired access token using the refresh token.
141
+ * It was a SECOND caller of the same endpoint, and only the first
142
+ * (better-auth.ts, card 138635) was hardened — the same duplicated-integration
143
+ * trap the viability code documents ("adding the relay to just one would leave
144
+ * an app that mounts THIS one still blind").
216
145
  *
217
- * @param refreshToken - The refresh token from previous login
218
- * @param mfaContext - MFA context to preserve across refresh
219
- * @returns New tokens or error
146
+ * It had no call sites and was not reachable through the package exports map
147
+ * (no ./auth/utils key, no wildcard), so removing it breaks nothing. Deleted
148
+ * rather than fixed, because an unreachable function whose only outcome is a
149
+ * 401 that LOOKS like a security success is a trap for whoever finds it next.
220
150
  */
221
151
  async function idpRefreshToken(refreshToken, mfaContext) {
222
152
  const idpUrl = getIdpUrl();
@@ -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,8 +158,16 @@ 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;
161
169
  const body = {
162
- refresh_token: session.idpRefreshToken,
170
+ refresh_token: sentRefreshToken,
163
171
  amr: authMethods,
164
172
  acr: acrValue,
165
173
  };
@@ -209,7 +217,10 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
209
217
  // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
210
218
  // a concurrent rotation is NOT a terminal condition.
211
219
  const postRefresh = await (0, session_store_1.getSession)(sessionToken);
212
- const tokenUnchanged = !!session.idpRefreshToken && postRefresh?.idpRefreshToken === session.idpRefreshToken;
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;
213
224
  if (tokenUnchanged) {
214
225
  await (0, session_store_1.updateSession)(sessionToken, {
215
226
  idpRefreshToken: '',