@payez/next-mvp 4.1.20 → 4.4.0

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 (177) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/components/account/UserAvatarMenu.js +12 -0
  31. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  32. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  33. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  34. package/dist/hooks/useAuthSettings.js +14 -2
  35. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  36. package/dist/hooks/usePublicAuthSettings.js +28 -14
  37. package/dist/lib/client-key.d.ts +61 -0
  38. package/dist/lib/client-key.js +72 -0
  39. package/dist/lib/demo-mode.js +2 -1
  40. package/dist/lib/ensure-fresh-access-token.js +12 -1
  41. package/dist/lib/idp-client-config.d.ts +25 -2
  42. package/dist/lib/idp-client-config.js +381 -36
  43. package/dist/lib/idp-fetch.d.ts +2 -0
  44. package/dist/lib/idp-fetch.js +31 -1
  45. package/dist/lib/idp-url-guard.d.ts +29 -0
  46. package/dist/lib/idp-url-guard.js +38 -0
  47. package/dist/lib/redis.js +77 -5
  48. package/dist/lib/session-cookie-shape.d.ts +69 -0
  49. package/dist/lib/session-cookie-shape.js +126 -0
  50. package/dist/lib/session-store.d.ts +35 -2
  51. package/dist/lib/session-store.js +135 -4
  52. package/dist/lib/single-flight.d.ts +35 -0
  53. package/dist/lib/single-flight.js +30 -0
  54. package/dist/lib/token-lifecycle.js +12 -0
  55. package/dist/middleware/create-middleware.js +12 -0
  56. package/dist/models/SessionModel.d.ts +41 -0
  57. package/dist/models/SessionModel.js +9 -0
  58. package/dist/pages/admin-login/page.d.ts +1 -1
  59. package/dist/pages/admin-login/page.js +5 -41
  60. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  61. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  62. package/dist/pages/security/index.d.ts +1 -0
  63. package/dist/pages/security/index.js +3 -0
  64. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  65. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  66. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  67. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  68. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  69. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  70. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  71. package/dist/pages/test-env/TestEnvPage.js +16 -4
  72. package/dist/pages/test-env/guarded.d.ts +4 -0
  73. package/dist/pages/test-env/guarded.js +86 -0
  74. package/dist/pages/test-env/index.d.ts +50 -7
  75. package/dist/pages/test-env/index.js +58 -12
  76. package/dist/routes/auth/logout.js +3 -2
  77. package/dist/routes/auth/refresh.js +27 -1
  78. package/dist/routes/auth/session.d.ts +12 -3
  79. package/dist/routes/auth/session.js +92 -9
  80. package/dist/routes/auth/settings.d.ts +34 -4
  81. package/dist/routes/auth/settings.js +87 -33
  82. package/dist/routes/auth/viability.d.ts +1 -1
  83. package/dist/routes/auth/viability.js +122 -11
  84. package/dist/server/auth-guard.js +22 -15
  85. package/dist/server/auth.d.ts +170 -4
  86. package/dist/server/auth.js +42 -75
  87. package/dist/server/decode-session.js +84 -10
  88. package/dist/types/auth.d.ts +18 -0
  89. package/package.json +969 -958
  90. package/scripts/check-internal-url-usage.sh +73 -73
  91. package/scripts/check-publish-health.mjs +120 -0
  92. package/scripts/dev-broker.ps1 +71 -6
  93. package/scripts/print-client-config.mjs +162 -0
  94. package/scripts/server-only-stub.cjs +4 -0
  95. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  96. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  97. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  98. package/src/api-handlers/account/reset-password.ts +35 -2
  99. package/src/api-handlers/account/validate-password.ts +22 -3
  100. package/src/api-handlers/admin/analytics.ts +2 -1
  101. package/src/api-handlers/admin/audit.ts +2 -1
  102. package/src/api-handlers/admin/sessions.ts +2 -1
  103. package/src/api-handlers/admin/stats.ts +2 -1
  104. package/src/api-handlers/admin/users.ts +2 -1
  105. package/src/api-handlers/admin/vibe-data.ts +2 -1
  106. package/src/api-handlers/auth/jwks.ts +10 -0
  107. package/src/api-handlers/auth/login.ts +5 -2
  108. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  109. package/src/api-handlers/auth/refresh.ts +112 -15
  110. package/src/api-handlers/auth/signout.ts +4 -3
  111. package/src/api-handlers/auth/verify-code.ts +5 -2
  112. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  113. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  114. package/src/api-handlers/mfa/methods.ts +46 -46
  115. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  116. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  117. package/src/api-handlers/passkeys/list.ts +31 -31
  118. package/src/api-handlers/passkeys/manage.ts +54 -54
  119. package/src/api-handlers/passkeys/register-options.ts +42 -42
  120. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  121. package/src/api-handlers/session/viability.ts +29 -1
  122. package/src/api-handlers/test/force-expire.ts +118 -13
  123. package/src/auth/better-auth.ts +486 -51
  124. package/src/auth/utils/idp-client.ts +1 -1
  125. package/src/client/AuthContext.tsx +55 -19
  126. package/src/components/account/UserAvatarMenu.tsx +11 -0
  127. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  128. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  129. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  130. package/src/hooks/useAuthSettings.ts +14 -2
  131. package/src/hooks/usePublicAuthSettings.ts +38 -16
  132. package/src/lib/client-key.ts +88 -0
  133. package/src/lib/demo-mode.ts +2 -1
  134. package/src/lib/ensure-fresh-access-token.ts +12 -1
  135. package/src/lib/idp-client-config.ts +399 -36
  136. package/src/lib/idp-fetch.ts +36 -1
  137. package/src/lib/idp-url-guard.ts +37 -0
  138. package/src/lib/redis.ts +84 -6
  139. package/src/lib/session-cookie-shape.ts +184 -0
  140. package/src/lib/session-store.ts +152 -6
  141. package/src/lib/single-flight.ts +64 -0
  142. package/src/lib/token-lifecycle.ts +12 -0
  143. package/src/middleware/create-middleware.ts +11 -0
  144. package/src/models/SessionModel.ts +49 -0
  145. package/src/pages/admin-login/page.tsx +17 -127
  146. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  147. package/src/pages/security/index.ts +3 -0
  148. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  149. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  150. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  151. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  152. package/src/pages/test-env/guarded.tsx +81 -0
  153. package/src/pages/test-env/index.ts +59 -7
  154. package/src/routes/auth/logout.ts +4 -3
  155. package/src/routes/auth/passkey-signin.ts +13 -13
  156. package/src/routes/auth/refresh.ts +30 -1
  157. package/src/routes/auth/session.ts +98 -9
  158. package/src/routes/auth/settings.ts +144 -42
  159. package/src/routes/auth/viability.ts +129 -11
  160. package/src/routes/mfa/backup-code-verify.ts +1 -1
  161. package/src/routes/mfa/backup-codes.ts +10 -10
  162. package/src/routes/mfa/methods.ts +22 -22
  163. package/src/routes/mfa/totp-confirm.ts +10 -10
  164. package/src/routes/mfa/totp-enroll.ts +10 -10
  165. package/src/routes/mfa/totp-verify.ts +14 -14
  166. package/src/routes/passkeys/list.ts +10 -10
  167. package/src/routes/passkeys/manage.ts +23 -23
  168. package/src/routes/passkeys/register-options.ts +10 -10
  169. package/src/routes/passkeys/register-verify.ts +10 -10
  170. package/src/server/auth-guard.ts +24 -17
  171. package/src/server/auth.ts +54 -74
  172. package/src/server/decode-session.ts +95 -11
  173. package/src/templates/instrumentation.ts +21 -2
  174. package/src/types/auth.ts +20 -0
  175. package/dist/lib/auth-secret.d.ts +0 -17
  176. package/dist/lib/auth-secret.js +0 -116
  177. package/src/lib/auth-secret.ts +0 -137
@@ -72,21 +72,57 @@ async function getTenantRequiresTwoFactor() {
72
72
  * nullable require_2fa fix: "absent" has to be expressible, or callers cannot
73
73
  * tell a real value from a default.
74
74
  *
75
- * No local default: `allowed2FAMethods` is non-optional on AuthSettings and is
76
- * populated on BOTH config construction paths (idp-client-config.ts:333, :514)
77
- * with the tenant's value or the layer's own ['email','sms']. Repeating that
78
- * default here would be a second source for one setting, free to drift.
75
+ * No local default. 257255 UPDATE: allowed2FAMethods is now OPTIONAL on
76
+ * AuthSettings - the config mapper no longer substitutes ['email','sms'] when
77
+ * the document carries no policy, because that made "no policy" and "exactly
78
+ * these methods" indistinguishable. So absent now correctly reaches this
79
+ * function as undefined and is reported as UNKNOWN (null), which is what this
80
+ * function's own contract already said it wanted. Before the change it would
81
+ * have returned the mapper's fabrication AS IF IT WERE THE TENANT'S VALUE.
79
82
  */
80
83
  async function getTenantAllowed2FAMethods() {
81
84
  try {
82
85
  const config = await (0, idp_client_config_1.getIDPClientConfig)();
83
- return config.authSettings.allowed2FAMethods;
86
+ // `?? null` is the whole point: undefined means the document carried no
87
+ // policy, and this function reports UNKNOWN rather than guessing.
88
+ return config.authSettings.allowed2FAMethods ?? null;
84
89
  }
85
90
  catch {
86
91
  console.warn('[VIABILITY] Could not get client config — reporting allowed 2FA methods as UNKNOWN (null), not guessing');
87
92
  return null;
88
93
  }
89
94
  }
95
+ /**
96
+ * Resolve the client id reported to the caller.
97
+ *
98
+ * Was: `session?.idpClientId || process.env.IDP_CLIENT_ID || ''`. Two defects,
99
+ * both of the "report a confident value where the truth is unknown" family that
100
+ * card 221076 was about:
101
+ *
102
+ * 1. `|| ''` made "we do not know this session's client" indistinguishable from
103
+ * "the client id is blank". Callers cannot tell absence from a known-empty
104
+ * value, so nothing can react to the former. Absence is now `null`, which is
105
+ * representable — and still falsy, so existing `if (!clientId)` guards behave
106
+ * exactly as before.
107
+ *
108
+ * 2. The env fallback SILENTLY substitutes the deployment's default client for a
109
+ * session whose own client is unknown. On a multi-tenant IDP that is a
110
+ * cross-tenant wrong answer, not a default. It is kept for single-tenant
111
+ * deployments that rely on it, but it now says so in the log rather than
112
+ * passing the substitution off as the session's own identity.
113
+ */
114
+ function resolveClientId(sessionClientId) {
115
+ if (sessionClientId)
116
+ return sessionClientId;
117
+ const envClientId = process.env.IDP_CLIENT_ID;
118
+ if (envClientId) {
119
+ console.warn('[VIABILITY_ROUTE] session carries no idpClientId - substituting IDP_CLIENT_ID ' +
120
+ 'from the environment. On a multi-tenant deployment this is a DIFFERENT client ' +
121
+ 'than the session belongs to.');
122
+ return envClientId;
123
+ }
124
+ return null;
125
+ }
90
126
  /**
91
127
  * GET /api/session/viability - Check if session is viable
92
128
  *
@@ -157,9 +193,84 @@ async function GET(req) {
157
193
  // default that reads like a measurement. Same mistake as the nullable
158
194
  // require_2fa case: absent has to stay expressible.
159
195
  const nextStep = session?.nextStep;
196
+ /**
197
+ * 266007 — port the sibling's CRITICAL override (api-handlers/session/
198
+ * viability.ts:82-86) into this, the impl consumers actually MOUNT.
199
+ * An OAuth session whose callback set mfaVerified === false because no
200
+ * refresh_token was issued ALWAYS needs 2FA completion, whatever the
201
+ * tenant config says — completing 2FA is how it obtains one.
202
+ *
203
+ * STRICT === false, deliberately: an ABSENT mfaVerified (every session
204
+ * minted before that field existed) is not a signal, and treating it as
205
+ * one would force-rechallenge the entire existing session population.
206
+ *
207
+ * *** R807 (BAPert, card 362550) - WHY THE `=== false` TEST ALONE NO LONGER
208
+ * CARRIES THAT INTENT, AND WHAT RESTORES IT. ***
209
+ *
210
+ * The intent above is right, but by the time a session reaches here the
211
+ * distinction it depends on IS ALREADY GONE: every construction site
212
+ * (session-store, decode-session, token-lifecycle x2, SessionModel) collapses
213
+ * an absent mfaVerified to `false` via `?? false`. So `=== false` had stopped
214
+ * meaning "the callback affirmatively said false" and had come to mean
215
+ * "affirmatively false OR never knew" - i.e. the exact mass re-challenge this
216
+ * comment says it is preventing. The guard read as strict while behaving as
217
+ * permissive, because the collapse happens upstream and out of sight.
218
+ *
219
+ * R807 keeps `mfaVerified` fail-closed at all five sites (removing `?? false`
220
+ * would be an AUTH BYPASS - a session that never proved MFA read as having
221
+ * proved it) and gives THIS question its own signal instead. So the test is
222
+ * now two parts:
223
+ *
224
+ * mfaSignalPresent === true -> the producer really did emit a value, so the
225
+ * `false` below is a MEASUREMENT. Fire.
226
+ * absent / false -> LEGACY session (minted before the signal
227
+ * existed) or a path with no IDP tokens at all.
228
+ * NOT a signal. Do NOT force a re-challenge.
229
+ *
230
+ * That absent-means-do-not-fire read is the migration behaviour, and it is
231
+ * what preserves this comment's original guarantee verbatim through rollout:
232
+ * on the day this ships, EVERY existing session has no signal and none of them
233
+ * is re-challenged.
234
+ *
235
+ * *** AND IT IS SAFE ONLY BECAUSE IT IS CONFINED HERE. *** mfaSignalPresent is
236
+ * permissive by construction - it can only ever SUPPRESS this override. It must
237
+ * never reach a path that grants access; gate those on `mfaVerified`, which is
238
+ * still fail-closed everywhere. R807 is void if this signal escapes into an
239
+ * auth decision.
240
+ *
241
+ * Declared once here, like nextStep above, so the three authenticated
242
+ * responses cannot drift apart. Feeds the legacy `requires2FA` field the
243
+ * middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
244
+ * it names TENANT policy, and this override is not tenant state.
245
+ */
246
+ const oauthSessionNeedsTwoFactor = session?.mfaSignalPresent === true &&
247
+ session?.mfaVerified === false &&
248
+ !(session.idpRefreshToken || session?.refreshToken);
160
249
  // Check access token expiry
161
250
  const now = Math.floor(Date.now() / 1000);
162
- const accessTokenExpires = token.accessTokenExpires || token.exp;
251
+ // 240654 addendum 2: fall back to the Redis session's own expiry.
252
+ //
253
+ // The first two reads come off the Better Auth session OBJECT. A Path-B
254
+ // synthetic session — the shape server/auth.ts builds for a legacy JWS
255
+ // session — carries NEITHER: it is `{session:{token}, user:{...}}` with no
256
+ // accessTokenExpires and no exp. So every legacy session fell into the
257
+ // "no expiry info" branch below and answered needsRefresh:true on EVERY
258
+ // viability read from the moment of login, while the token was in fact
259
+ // fresh (15-minute TTL measured). Benign for the viable/authenticated
260
+ // verdict, but it recommends a refresh constantly, which is pure perf
261
+ // noise and multiplies exposure to the empty-clientId refresh path (223542).
262
+ //
263
+ // lib/session-store puts the real value on the session as
264
+ // idpAccessTokenExpires, in MILLISECONDS (better-auth.ts:784 sets
265
+ // `Date.now() + expires_in * 1000`). The other two sources are in SECONDS.
266
+ // Mixing units here would be a silent disaster, so note that the
267
+ // normalisation below (`> 1e12 ? /1000 : as-is`) already discriminates
268
+ // them by magnitude: a seconds timestamp is ~1.7e9, a milliseconds one
269
+ // ~1.7e12. This fallback is therefore safe in either unit, and that
270
+ // existing normaliser is load-bearing for it -- do not remove it.
271
+ const accessTokenExpires = token.accessTokenExpires ||
272
+ token.exp ||
273
+ session?.idpAccessTokenExpires;
163
274
  if (!accessTokenExpires) {
164
275
  // No expiry info, assume viable but recommend refresh
165
276
  const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
@@ -186,7 +297,7 @@ async function GET(req) {
186
297
  tenantAllowed2FAMethods,
187
298
  nextStep,
188
299
  // Legacy field names for backwards compatibility
189
- requires2FA: tenantRequiresTwoFactor,
300
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
190
301
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
191
302
  accessTokenExpired: false,
192
303
  reason: 'No expiry information'
@@ -246,13 +357,13 @@ async function GET(req) {
246
357
  tenantAllowed2FAMethods,
247
358
  nextStep,
248
359
  // Legacy names for backwards compatibility
249
- requires2FA: tenantRequiresTwoFactor,
360
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
250
361
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
251
362
  accessTokenExpired: true,
252
363
  reason: 'Token expired',
253
364
  // RBAC fields
254
365
  roles: session?.roles || [],
255
- clientId: session?.idpClientId || process.env.IDP_CLIENT_ID || '',
366
+ clientId: resolveClientId(session?.idpClientId),
256
367
  });
257
368
  }
258
369
  return server_1.NextResponse.json({
@@ -269,13 +380,13 @@ async function GET(req) {
269
380
  tenantAllowed2FAMethods,
270
381
  nextStep,
271
382
  // Legacy names for backwards compatibility
272
- requires2FA: tenantRequiresTwoFactor,
383
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
273
384
  twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
274
385
  accessTokenExpired: false,
275
386
  expiresAt: new Date(expiryTime * 1000).toISOString(),
276
387
  // RBAC fields
277
388
  roles: session?.roles || [],
278
- clientId: session?.idpClientId || process.env.IDP_CLIENT_ID || '',
389
+ clientId: resolveClientId(session?.idpClientId),
279
390
  });
280
391
  }
281
392
  catch (error) {
@@ -70,29 +70,36 @@ async function authGuard(options) {
70
70
  (0, navigation_1.redirect)(`${loginUrl}?callbackUrl=${callbackUrl}&reason=invalidated`);
71
71
  }
72
72
  // --- 2FA check ---
73
+ // This try wraps ONLY the config fetch, because that is the only thing here
74
+ // that can genuinely fail. Do NOT widen it to cover the redirect below:
75
+ // next/navigation's redirect() signals by THROWING a NEXT_REDIRECT digest, so
76
+ // a redirect inside this try is caught by the catch and rewritten into a
77
+ // service-unavailable page - denying a 2FA user the one page that would let
78
+ // them satisfy the challenge, and reporting it as an outage. See card 312242.
79
+ let requires2FA;
73
80
  try {
74
81
  const config = await (0, idp_client_config_1.getIDPClientConfig)();
75
- const requires2FA = config.authSettings?.require2FA ?? true;
76
- if (requires2FA) {
77
- const mfaVerified = sessionData.mfaVerified ?? sessionData.twoFactorComplete ?? false;
78
- const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
79
- const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
80
- if (!mfaVerified || mfaExpired) {
81
- console.log('[AUTH-GUARD] 2FA required', {
82
- mfaVerified,
83
- mfaExpired,
84
- userId: sessionData.userId,
85
- pathname,
86
- });
87
- (0, navigation_1.redirect)(`${verifyCodeUrl}?callbackUrl=${callbackUrl}`);
88
- }
89
- }
82
+ requires2FA = config.authSettings?.require2FA ?? true;
90
83
  }
91
84
  catch (error) {
92
85
  // If we can't check 2FA config, fail closed
93
86
  console.error('[AUTH-GUARD] 2FA config check failed:', error instanceof Error ? error.message : String(error));
94
87
  (0, navigation_1.redirect)(serviceUnavailableUrl);
95
88
  }
89
+ if (requires2FA) {
90
+ const mfaVerified = sessionData.mfaVerified ?? sessionData.twoFactorComplete ?? false;
91
+ const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
92
+ const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
93
+ if (!mfaVerified || mfaExpired) {
94
+ console.log('[AUTH-GUARD] 2FA required', {
95
+ mfaVerified,
96
+ mfaExpired,
97
+ userId: sessionData.userId,
98
+ pathname,
99
+ });
100
+ (0, navigation_1.redirect)(`${verifyCodeUrl}?callbackUrl=${callbackUrl}`);
101
+ }
102
+ }
96
103
  // --- Custom checks (beta, admin, etc.) ---
97
104
  if (options?.checks) {
98
105
  for (const check of options.checks) {
@@ -12,12 +12,18 @@ export type IdpTokenResult = {
12
12
  sessionData: SessionData;
13
13
  } | {
14
14
  success: false;
15
- error: 'NO_SESSION' | 'NO_TOKEN';
15
+ error: 'NO_SESSION';
16
16
  terminal: true;
17
+ } | {
18
+ success: false;
19
+ error: 'NO_TOKEN';
20
+ terminal: true;
21
+ } | {
22
+ success: false;
23
+ error: 'NO_TOKEN_PENDING_2FA';
24
+ terminal: false;
25
+ sessionData: SessionData;
17
26
  };
18
- /**
19
- * Get the initialized Better Auth instance (singleton).
20
- */
21
27
  export declare function getAuthInstance(): Promise<import("better-auth/types").Auth<{
22
28
  baseURL: string;
23
29
  secret: string;
@@ -150,6 +156,166 @@ export declare function getAuthInstance(): Promise<import("better-auth/types").A
150
156
  max: number;
151
157
  }[];
152
158
  options: import("better-auth/plugins/magic-link").MagicLinkOptions;
159
+ } | {
160
+ id: "generic-oauth";
161
+ init: (ctx: import("@better-auth/core").AuthContext) => {
162
+ context: {
163
+ socialProviders: import("better-auth/*").OAuthProvider<Record<string, any>, Partial<import("better-auth/*").ProviderOptions<any>>>[];
164
+ };
165
+ };
166
+ endpoints: {
167
+ signInWithOAuth2: import("better-call").StrictEndpoint<"/sign-in/oauth2", {
168
+ method: "POST";
169
+ body: import("zod").ZodObject<{
170
+ providerId: import("zod").ZodString;
171
+ callbackURL: import("zod").ZodOptional<import("zod").ZodString>;
172
+ errorCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
173
+ newUserCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
174
+ disableRedirect: import("zod").ZodOptional<import("zod").ZodBoolean>;
175
+ scopes: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
176
+ requestSignUp: import("zod").ZodOptional<import("zod").ZodBoolean>;
177
+ additionalData: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodAny>>;
178
+ }, import("better-auth/*").$strip>;
179
+ metadata: {
180
+ openapi: {
181
+ description: string;
182
+ responses: {
183
+ 200: {
184
+ description: string;
185
+ content: {
186
+ "application/json": {
187
+ schema: {
188
+ type: "object";
189
+ properties: {
190
+ url: {
191
+ type: string;
192
+ };
193
+ redirect: {
194
+ type: string;
195
+ };
196
+ };
197
+ };
198
+ };
199
+ };
200
+ };
201
+ };
202
+ };
203
+ };
204
+ }, {
205
+ url: string;
206
+ redirect: boolean;
207
+ }>;
208
+ oAuth2Callback: import("better-call").StrictEndpoint<"/oauth2/callback/:providerId", {
209
+ method: "GET";
210
+ query: import("zod").ZodObject<{
211
+ code: import("zod").ZodOptional<import("zod").ZodString>;
212
+ error: import("zod").ZodOptional<import("zod").ZodString>;
213
+ error_description: import("zod").ZodOptional<import("zod").ZodString>;
214
+ state: import("zod").ZodOptional<import("zod").ZodString>;
215
+ iss: import("zod").ZodOptional<import("zod").ZodString>;
216
+ }, import("better-auth/*").$strip>;
217
+ metadata: {
218
+ allowedMediaTypes: string[];
219
+ openapi: {
220
+ description: string;
221
+ responses: {
222
+ 200: {
223
+ description: string;
224
+ content: {
225
+ "application/json": {
226
+ schema: {
227
+ type: "object";
228
+ properties: {
229
+ url: {
230
+ type: string;
231
+ };
232
+ };
233
+ };
234
+ };
235
+ };
236
+ };
237
+ };
238
+ };
239
+ scope: "server";
240
+ };
241
+ }, void>;
242
+ oAuth2LinkAccount: import("better-call").StrictEndpoint<"/oauth2/link", {
243
+ method: "POST";
244
+ body: import("zod").ZodObject<{
245
+ providerId: import("zod").ZodString;
246
+ callbackURL: import("zod").ZodString;
247
+ scopes: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
248
+ errorCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
249
+ }, import("better-auth/*").$strip>;
250
+ use: ((inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
251
+ session: {
252
+ session: Record<string, any> & {
253
+ id: string;
254
+ createdAt: Date;
255
+ updatedAt: Date;
256
+ userId: string;
257
+ expiresAt: Date;
258
+ token: string;
259
+ ipAddress?: string | null | undefined;
260
+ userAgent?: string | null | undefined;
261
+ };
262
+ user: Record<string, any> & {
263
+ id: string;
264
+ createdAt: Date;
265
+ updatedAt: Date;
266
+ email: string;
267
+ emailVerified: boolean;
268
+ name: string;
269
+ image?: string | null | undefined;
270
+ };
271
+ };
272
+ }>)[];
273
+ metadata: {
274
+ openapi: {
275
+ description: string;
276
+ responses: {
277
+ "200": {
278
+ description: string;
279
+ content: {
280
+ "application/json": {
281
+ schema: {
282
+ type: "object";
283
+ properties: {
284
+ url: {
285
+ type: string;
286
+ format: string;
287
+ description: string;
288
+ };
289
+ redirect: {
290
+ type: string;
291
+ description: string;
292
+ enum: boolean[];
293
+ };
294
+ };
295
+ required: string[];
296
+ };
297
+ };
298
+ };
299
+ };
300
+ };
301
+ };
302
+ };
303
+ }, {
304
+ url: string;
305
+ redirect: boolean;
306
+ }>;
307
+ };
308
+ options: import("better-auth/plugins/generic-oauth").GenericOAuthOptions;
309
+ $ERROR_CODES: {
310
+ INVALID_OAUTH_CONFIGURATION: import("better-auth/*").RawError<"INVALID_OAUTH_CONFIGURATION">;
311
+ TOKEN_URL_NOT_FOUND: import("better-auth/*").RawError<"TOKEN_URL_NOT_FOUND">;
312
+ PROVIDER_CONFIG_NOT_FOUND: import("better-auth/*").RawError<"PROVIDER_CONFIG_NOT_FOUND">;
313
+ PROVIDER_ID_REQUIRED: import("better-auth/*").RawError<"PROVIDER_ID_REQUIRED">;
314
+ INVALID_OAUTH_CONFIG: import("better-auth/*").RawError<"INVALID_OAUTH_CONFIG">;
315
+ SESSION_REQUIRED: import("better-auth/*").RawError<"SESSION_REQUIRED">;
316
+ ISSUER_MISMATCH: import("better-auth/*").RawError<"ISSUER_MISMATCH">;
317
+ ISSUER_MISSING: import("better-auth/*").RawError<"ISSUER_MISSING">;
318
+ };
153
319
  })[]];
154
320
  }>>;
155
321
  /**
@@ -49,9 +49,9 @@ require("server-only");
49
49
  const better_auth_1 = require("../auth/better-auth");
50
50
  const idp_client_config_1 = require("../lib/idp-client-config");
51
51
  const session_store_1 = require("../lib/session-store");
52
- const app_slug_1 = require("../lib/app-slug");
52
+ const session_cookie_shape_1 = require("../lib/session-cookie-shape");
53
+ const single_flight_1 = require("../lib/single-flight");
53
54
  let authInstance = null;
54
- let authInitPromise = null;
55
55
  function buildSessionDataFromAuthSession(session) {
56
56
  const user = session?.user;
57
57
  if (!user?.id && !user?.email) {
@@ -105,82 +105,41 @@ function attachSessionData(session, sessionData, sessionToken) {
105
105
  }
106
106
  /**
107
107
  * Get the initialized Better Auth instance (singleton).
108
+ *
109
+ * 244549: the single-flight memoisation lives in lib/single-flight.ts rather
110
+ * than inline here, and that placement is deliberate rather than tidiness.
111
+ * The bug being fixed -- a REJECTED init promise cached for the life of the
112
+ * process, so recovery was restart-only -- went unnoticed precisely because
113
+ * this module cannot be loaded in a unit test: importing it pulls in
114
+ * better-auth and the whole IDP config stack. Extracted into a zero-import
115
+ * module, the retry semantics are covered by
116
+ * test/single-flight-retry.test.cjs, which imports dist/ and therefore
117
+ * exercises the artefact that actually ships.
118
+ *
119
+ * Semantics, all asserted by that suite: concurrent callers share ONE
120
+ * underlying init; a rejection reaches every caller awaiting that attempt
121
+ * (failures stay loud -- getIDPClientConfig is fail-loud by design and callers
122
+ * depend on it); the NEXT call after a failure starts a fresh attempt; and a
123
+ * slow failure never evicts a newer in-flight attempt.
108
124
  */
125
+ const authInit = (0, single_flight_1.createSingleFlight)(async () => {
126
+ const config = await (0, idp_client_config_1.getIDPClientConfig)(true);
127
+ authInstance = (0, better_auth_1.createBetterAuthInstance)(config);
128
+ // 185121 line 1, path 2: this module-scope instance had NO creation log
129
+ // line — first-called during a config transition window it pinned a
130
+ // divergent secret INVISIBLY. Name the path and the secret fingerprint
131
+ // (the createBetterAuthInstance line already logged the same hash;
132
+ // this line is what attributes it to THIS init path).
133
+ // Guard matches createBetterAuthInstance's: getIDPClientConfig currently
134
+ // throws FATAL on a missing authSecret (QAPert 27328 verified), but that
135
+ // guarantee is one refactor away — log ABSENT rather than ever throw here.
136
+ console.log(`[AUTH] getAuthInstance: created BA instance via server/auth.ts — authSecret ${config.authSecret ? `sha256:${(0, better_auth_1.secretFingerprint)(config.authSecret)}` : 'ABSENT'}`);
137
+ return authInstance;
138
+ });
109
139
  async function getAuthInstance() {
110
140
  if (authInstance)
111
141
  return authInstance;
112
- if (!authInitPromise) {
113
- authInitPromise = (0, idp_client_config_1.getIDPClientConfig)(true).then(config => {
114
- authInstance = (0, better_auth_1.createBetterAuthInstance)(config);
115
- return authInstance;
116
- });
117
- }
118
- return authInitPromise;
119
- }
120
- /**
121
- * JWT body-decode helper — base64-decode only, NO signature verification.
122
- * Safe because the value is then used as a Redis lookup key: Redis is the
123
- * liveness gate, so an attacker forging a JWT body with a guessed
124
- * sessionToken claim still has to land on a real Redis session (infeasible
125
- * against high-entropy UUIDs). Used by the canonical-fallback path in
126
- * `getSession` to extract the session token when Better Auth's primary
127
- * path returns null (cookie cache miss / secondary storage eviction).
128
- */
129
- function decodeJwtBody(jwt) {
130
- try {
131
- const parts = jwt.split('.');
132
- if (parts.length !== 3)
133
- return null;
134
- const decoded = Buffer.from(parts[1], 'base64').toString('utf-8');
135
- return JSON.parse(decoded);
136
- }
137
- catch {
138
- return null;
139
- }
140
- }
141
- /** Extract the session-token claim from the Better Auth session cookie
142
- * WITHOUT going through Better Auth's `auth.api.getSession()`. Handles
143
- * both single-cookie and chunked-cookie cases (Better Auth chunks long
144
- * JWTs across `{name}.0`, `{name}.1`, …). Returns the raw `sessionToken`
145
- * claim suitable for use as a Redis key. Returns null if no cookie is
146
- * present or the JWT can't be parsed. */
147
- function extractSessionTokenFromCookie(request) {
148
- const cookieHeader = request.headers.get('cookie');
149
- if (!cookieHeader)
150
- return null;
151
- const cookieName = (0, app_slug_1.getSessionCookieName)();
152
- let rawJwt = null;
153
- // Direct cookie first.
154
- const chunks = [];
155
- for (const part of cookieHeader.split(';')) {
156
- const trimmed = part.trim();
157
- const eq = trimmed.indexOf('=');
158
- if (eq === -1)
159
- continue;
160
- const k = trimmed.slice(0, eq);
161
- const v = trimmed.slice(eq + 1);
162
- if (k === cookieName) {
163
- rawJwt = decodeURIComponent(v);
164
- break;
165
- }
166
- if (k.startsWith(`${cookieName}.`)) {
167
- const idx = parseInt(k.split('.').pop() || '0', 10);
168
- if (Number.isFinite(idx))
169
- chunks.push({ idx, value: decodeURIComponent(v) });
170
- }
171
- }
172
- // Chunked cookies fallback (Better Auth splits long JWTs across .0/.1/.2 …).
173
- if (!rawJwt && chunks.length > 0) {
174
- chunks.sort((a, b) => a.idx - b.idx);
175
- rawJwt = chunks.map(c => c.value).join('');
176
- }
177
- if (!rawJwt)
178
- return null;
179
- const decoded = decodeJwtBody(rawJwt);
180
- if (decoded && typeof decoded === 'object' && typeof decoded.sessionToken === 'string') {
181
- return decoded.sessionToken;
182
- }
183
- return null;
142
+ return authInit.get();
184
143
  }
185
144
  /**
186
145
  * Get the current session from a request.
@@ -232,7 +191,7 @@ async function getSession(request) {
232
191
  // in-memory cookie cache returned stale-null. No JWT signature check —
233
192
  // Redis lookup is the validation: an attacker forging a session-token
234
193
  // claim hits NO_SESSION because Redis has no matching entry.
235
- const sessionToken = extractSessionTokenFromCookie(request);
194
+ const sessionToken = (0, session_cookie_shape_1.extractSessionTokenFromCookie)(request);
236
195
  if (!sessionToken)
237
196
  return null;
238
197
  let sessionData = null;
@@ -292,6 +251,14 @@ async function getIdpToken(request) {
292
251
  }
293
252
  const accessToken = sessionData.idpAccessToken || sessionData.accessToken;
294
253
  if (!accessToken) {
254
+ // Card 187871: a live Better Auth session with no idpAccessToken and
255
+ // mfaVerified !== true is a user mid-2FA, not a dead session - the IDP
256
+ // withholds the full token until 2FA completes (idp-fetch.ts:81/113).
257
+ // Only treat this as terminal once the session itself claims 2FA is
258
+ // already done; a token missing AFTER that is genuinely broken.
259
+ if (sessionData.mfaVerified !== true) {
260
+ return { success: false, error: 'NO_TOKEN_PENDING_2FA', terminal: false, sessionData };
261
+ }
295
262
  return { success: false, error: 'NO_TOKEN', terminal: true };
296
263
  }
297
264
  return {