@payez/next-mvp 4.8.1 → 4.8.2

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 (302) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/components/account/MobileNavDrawer.js +18 -18
  4. package/dist/components/account/UserAvatarMenu.js +1 -1
  5. package/dist/components/admin/AlertSettingsTab.js +40 -40
  6. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  7. package/dist/lib/session-store.js +21 -21
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/pages/login/page.js +28 -28
  10. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  11. package/dist/vibe/sessions.js +28 -3
  12. package/package.json +2 -2
  13. package/scripts/check-publish-eol.mjs +33 -15
  14. package/src/api/auth-handler.ts +550 -550
  15. package/src/api/index.ts +17 -17
  16. package/src/api-handlers/account/change-password.ts +170 -170
  17. package/src/api-handlers/account/masked-info.ts +46 -46
  18. package/src/api-handlers/account/profile.ts +99 -99
  19. package/src/api-handlers/account/recovery/forward.ts +117 -117
  20. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  21. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  22. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  23. package/src/api-handlers/account/reset-password.ts +87 -87
  24. package/src/api-handlers/account/send-code.ts +70 -70
  25. package/src/api-handlers/account/update-phone.ts +84 -84
  26. package/src/api-handlers/account/validate-password.ts +139 -139
  27. package/src/api-handlers/account/verify-email.ts +134 -134
  28. package/src/api-handlers/account/verify-sms.ts +134 -134
  29. package/src/api-handlers/admin/analytics.ts +452 -452
  30. package/src/api-handlers/admin/audit.ts +232 -232
  31. package/src/api-handlers/admin/index.ts +63 -63
  32. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  33. package/src/api-handlers/admin/sessions.ts +327 -327
  34. package/src/api-handlers/admin/site-logs.ts +388 -388
  35. package/src/api-handlers/admin/stats.ts +260 -260
  36. package/src/api-handlers/admin/users.ts +251 -251
  37. package/src/api-handlers/admin/vibe-data.ts +333 -333
  38. package/src/api-handlers/anon/preferences.ts +123 -123
  39. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  40. package/src/api-handlers/auth/credential-login.ts +948 -948
  41. package/src/api-handlers/auth/jwks.ts +31 -31
  42. package/src/api-handlers/auth/login.ts +252 -252
  43. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  44. package/src/api-handlers/auth/refresh.ts +829 -829
  45. package/src/api-handlers/auth/signout.ts +201 -201
  46. package/src/api-handlers/auth/status.ts +23 -23
  47. package/src/api-handlers/auth/update-session.ts +144 -144
  48. package/src/api-handlers/auth/validate.ts +45 -45
  49. package/src/api-handlers/auth/verify-code.ts +116 -116
  50. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  51. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  52. package/src/api-handlers/mfa/methods.ts +47 -47
  53. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  54. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  55. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  56. package/src/api-handlers/oauth/resume.ts +182 -182
  57. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  58. package/src/api-handlers/onboarding/set-password.ts +21 -21
  59. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  60. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  61. package/src/api-handlers/passkeys/list.ts +32 -32
  62. package/src/api-handlers/passkeys/manage.ts +55 -55
  63. package/src/api-handlers/passkeys/register-options.ts +43 -43
  64. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  65. package/src/api-handlers/session/refresh-viability.ts +56 -56
  66. package/src/api-handlers/session/viability.ts +238 -238
  67. package/src/api-handlers/test/force-expire.ts +167 -167
  68. package/src/auth/auth-decision.ts +292 -292
  69. package/src/auth/better-auth.ts +1688 -1688
  70. package/src/auth/idp-oauth.ts +310 -310
  71. package/src/auth/password-reset-bridge.ts +98 -98
  72. package/src/auth/revocation-fields.ts +16 -16
  73. package/src/auth/route-config.ts +236 -236
  74. package/src/auth/safe-callback.ts +199 -199
  75. package/src/auth/session-cookie-clear.ts +30 -30
  76. package/src/auth/session-reset-link.ts +159 -159
  77. package/src/auth/session-response-sanitizer.ts +151 -151
  78. package/src/auth/types/auth-types.ts +555 -555
  79. package/src/auth/types/index.ts +7 -7
  80. package/src/auth/unauthenticated-routes.ts +2 -2
  81. package/src/auth/utils/idp-client.ts +364 -364
  82. package/src/auth/utils/index.ts +6 -6
  83. package/src/auth/utils/token-utils.ts +243 -243
  84. package/src/client/AuthContext.tsx +192 -192
  85. package/src/client/better-auth-client.ts +70 -70
  86. package/src/client/fetch-with-auth.ts +47 -47
  87. package/src/client/fetchWithSession.ts +21 -21
  88. package/src/client/index.ts +13 -13
  89. package/src/client/useAnonSession.ts +131 -131
  90. package/src/components/SessionSync.tsx +138 -138
  91. package/src/components/SignalRHealthCheck.tsx +131 -131
  92. package/src/components/account/MobileNavDrawer.tsx +305 -305
  93. package/src/components/account/UserAvatarMenu.tsx +277 -277
  94. package/src/components/account/index.ts +13 -13
  95. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  96. package/src/components/admin/AnalyticsTab.tsx +703 -703
  97. package/src/components/admin/DataBrowserTab.tsx +508 -508
  98. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  99. package/src/components/admin/SessionsTab.tsx +421 -421
  100. package/src/components/admin/StatsTab.tsx +379 -379
  101. package/src/components/admin/VibeAdminContext.tsx +87 -87
  102. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  103. package/src/components/admin/index.ts +59 -59
  104. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  105. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  106. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  107. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  108. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  109. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  110. package/src/components/recovery/CompleteStep.tsx +36 -36
  111. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  112. package/src/components/recovery/RequestedStep.tsx +30 -30
  113. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  114. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  115. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  116. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  117. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  118. package/src/components/ui/BetaBadge.tsx +58 -58
  119. package/src/components/ui/Footer.tsx +93 -93
  120. package/src/config/env.ts +55 -55
  121. package/src/config/logger.ts +62 -62
  122. package/src/config/logging-config.ts +82 -82
  123. package/src/config/unauthenticated-routes.ts +19 -19
  124. package/src/config/vibe-log-transport.ts +264 -264
  125. package/src/edge/internal-api-url.ts +65 -65
  126. package/src/edge/middleware.ts +42 -42
  127. package/src/hooks/useAuth.ts +117 -117
  128. package/src/hooks/useAuthSettings.ts +109 -109
  129. package/src/hooks/useAvailableProviders.ts +143 -143
  130. package/src/hooks/usePasswordValidation.ts +127 -127
  131. package/src/hooks/useProfile.ts +82 -82
  132. package/src/hooks/usePublicAuthSettings.ts +171 -171
  133. package/src/hooks/useSessionExpiration.ts +101 -101
  134. package/src/hooks/useViabilitySession.ts +337 -337
  135. package/src/index.ts +64 -64
  136. package/src/lib/anon-session.ts +213 -213
  137. package/src/lib/api-handler.ts +643 -643
  138. package/src/lib/app-slug.ts +228 -228
  139. package/src/lib/authorize-resume.ts +172 -172
  140. package/src/lib/bearer-fetch.ts +31 -31
  141. package/src/lib/client-key.ts +88 -88
  142. package/src/lib/code-step-copy.ts +83 -83
  143. package/src/lib/credential-sign-in-client.ts +101 -101
  144. package/src/lib/demo-mode.ts +18 -18
  145. package/src/lib/device-id.ts +67 -67
  146. package/src/lib/ensure-fresh-access-token.ts +509 -509
  147. package/src/lib/geolocation.ts +265 -265
  148. package/src/lib/idp-client-config.ts +1005 -1005
  149. package/src/lib/idp-fetch.ts +169 -169
  150. package/src/lib/idp-url-guard.ts +37 -37
  151. package/src/lib/internal-api.ts +171 -171
  152. package/src/lib/jwt-decode-client.ts +45 -45
  153. package/src/lib/jwt-decode.ts +82 -82
  154. package/src/lib/lock-window.ts +49 -49
  155. package/src/lib/login-error-message.ts +41 -41
  156. package/src/lib/never-in-response.ts +64 -64
  157. package/src/lib/nextauth-secret.ts +168 -168
  158. package/src/lib/onboarding-proxy.ts +57 -57
  159. package/src/lib/password-rules.ts +50 -50
  160. package/src/lib/rate-limit-service.ts +9 -9
  161. package/src/lib/redis.ts +105 -105
  162. package/src/lib/refresh-token-validator.ts +64 -64
  163. package/src/lib/roles.ts +177 -177
  164. package/src/lib/safe-error-response.ts +149 -149
  165. package/src/lib/secret-validation.ts +8 -8
  166. package/src/lib/session-cookie-shape.ts +185 -185
  167. package/src/lib/session-store.ts +1092 -1092
  168. package/src/lib/session.ts +34 -34
  169. package/src/lib/single-flight.ts +64 -64
  170. package/src/lib/site-logger.ts +245 -245
  171. package/src/lib/standardized-client-api.ts +901 -901
  172. package/src/lib/startup-init.ts +276 -276
  173. package/src/lib/test-aware-get-token.ts +41 -41
  174. package/src/lib/token-expiry.ts +40 -40
  175. package/src/lib/token-lifecycle.ts +546 -546
  176. package/src/lib/token-log-id.ts +69 -69
  177. package/src/lib/twofa-rejection.ts +91 -91
  178. package/src/lib/types/api-responses.ts +336 -336
  179. package/src/lib/user-agent-parser.ts +252 -252
  180. package/src/logging/api/admin-analytics.ts +51 -51
  181. package/src/logging/api/audit-log.ts +53 -53
  182. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  183. package/src/logging/components/AuditLogViewer.tsx +125 -125
  184. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  185. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  186. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  187. package/src/logging/hooks/useAuditLog.ts +24 -24
  188. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  189. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  190. package/src/logging/index.ts +18 -18
  191. package/src/logging/types/analytics.ts +81 -81
  192. package/src/logging/types/audit.ts +31 -31
  193. package/src/logging/types/index.ts +3 -3
  194. package/src/middleware/create-middleware.ts +1141 -1141
  195. package/src/middleware/rbac-check.ts +279 -279
  196. package/src/middleware/twofa-presets.ts +224 -224
  197. package/src/models/DecodedAccessToken.ts +16 -16
  198. package/src/models/SessionModel.ts +331 -331
  199. package/src/pages/admin-login/page.tsx +111 -111
  200. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  201. package/src/pages/admin-page-permissions/index.ts +7 -7
  202. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  203. package/src/pages/admin-roles/index.ts +9 -9
  204. package/src/pages/admin-roles/modals.tsx +469 -469
  205. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  206. package/src/pages/client-admin/index.ts +33 -33
  207. package/src/pages/coming-soon/page.tsx +83 -83
  208. package/src/pages/login/page.tsx +338 -338
  209. package/src/pages/onboarding/index.ts +11 -11
  210. package/src/pages/onboarding/page.tsx +212 -212
  211. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  212. package/src/pages/profile/index.ts +9 -9
  213. package/src/pages/profile/page.tsx +166 -166
  214. package/src/pages/recovery/page.tsx +267 -267
  215. package/src/pages/roles/MyRolesPage.tsx +211 -211
  216. package/src/pages/roles/components.tsx +294 -294
  217. package/src/pages/roles/index.ts +17 -17
  218. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  219. package/src/pages/security/index.ts +12 -12
  220. package/src/pages/security/page.tsx +507 -507
  221. package/src/pages/service-unavailable/page.tsx +63 -63
  222. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  223. package/src/pages/settings/index.ts +9 -9
  224. package/src/pages/settings/page.tsx +47 -47
  225. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  226. package/src/pages/showcase/index.ts +13 -13
  227. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  228. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  229. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  230. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  231. package/src/pages/test-env/guarded.tsx +81 -81
  232. package/src/pages/test-env/index.ts +77 -77
  233. package/src/pages/test-env/redact-credentials.ts +96 -96
  234. package/src/pages/verify-code/page.tsx +746 -746
  235. package/src/routes/account/index.ts +31 -31
  236. package/src/routes/account/masked-info.ts +36 -36
  237. package/src/routes/account/send-code.ts +39 -39
  238. package/src/routes/account/update-phone.ts +13 -13
  239. package/src/routes/account/verify-email.ts +40 -40
  240. package/src/routes/account/verify-sms.ts +40 -40
  241. package/src/routes/auth/credential-login-email-code.ts +6 -6
  242. package/src/routes/auth/credential-login.ts +6 -6
  243. package/src/routes/auth/index.ts +22 -22
  244. package/src/routes/auth/logout.ts +111 -111
  245. package/src/routes/auth/nextauth.ts +45 -45
  246. package/src/routes/auth/passkey-signin.ts +13 -13
  247. package/src/routes/auth/refresh.ts +77 -77
  248. package/src/routes/auth/session-reset.ts +37 -37
  249. package/src/routes/auth/session.ts +261 -261
  250. package/src/routes/auth/settings.ts +181 -181
  251. package/src/routes/auth/viability.ts +519 -519
  252. package/src/routes/index.ts +17 -17
  253. package/src/routes/mfa/backup-code-verify.ts +1 -1
  254. package/src/routes/mfa/backup-codes.ts +10 -10
  255. package/src/routes/mfa/methods.ts +22 -22
  256. package/src/routes/mfa/totp-confirm.ts +10 -10
  257. package/src/routes/mfa/totp-enroll.ts +10 -10
  258. package/src/routes/mfa/totp-verify.ts +14 -14
  259. package/src/routes/onboarding/index.ts +15 -15
  260. package/src/routes/onboarding/send-email-code.ts +2 -2
  261. package/src/routes/onboarding/set-password.ts +2 -2
  262. package/src/routes/onboarding/verify-code.ts +2 -2
  263. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  264. package/src/routes/passkeys/list.ts +10 -10
  265. package/src/routes/passkeys/manage.ts +23 -23
  266. package/src/routes/passkeys/register-options.ts +10 -10
  267. package/src/routes/passkeys/register-verify.ts +10 -10
  268. package/src/routes/session/index.ts +7 -7
  269. package/src/routes/session/refresh-viability.ts +17 -17
  270. package/src/server/auth-guard.ts +203 -203
  271. package/src/server/auth.ts +486 -486
  272. package/src/server/decode-session.ts +309 -309
  273. package/src/server/slim-middleware.ts +117 -117
  274. package/src/server/verified-session-token.ts +103 -103
  275. package/src/server/with-auth.ts +93 -93
  276. package/src/services/signalrActivityService.ts +258 -258
  277. package/src/stores/authStore.ts +1900 -1900
  278. package/src/templates/instrumentation.ts +60 -60
  279. package/src/theme/ThemeProvider.tsx +39 -39
  280. package/src/theme/default.ts +33 -33
  281. package/src/theme/index.ts +31 -31
  282. package/src/theme/types.ts +69 -69
  283. package/src/theme/useTheme.ts +57 -57
  284. package/src/theme/utils.ts +40 -40
  285. package/src/types/api.ts +12 -12
  286. package/src/types/auth.d.ts +23 -23
  287. package/src/types/auth.ts +48 -48
  288. package/src/types/logging.ts +11 -11
  289. package/src/types/recovery.ts +54 -54
  290. package/src/utils/api.ts +401 -401
  291. package/src/utils/circuitBreaker.ts +40 -40
  292. package/src/utils/error-message.ts +108 -108
  293. package/src/utils/layout/reservedSpace.ts +124 -124
  294. package/src/utils/logout.ts +30 -30
  295. package/src/vibe/client.ts +877 -877
  296. package/src/vibe/enterprise-auth.ts +208 -208
  297. package/src/vibe/errors.ts +209 -209
  298. package/src/vibe/generic.ts +339 -339
  299. package/src/vibe/hooks/index.ts +367 -367
  300. package/src/vibe/index.ts +133 -133
  301. package/src/vibe/sessions.ts +925 -899
  302. package/src/vibe/types.ts +577 -577
@@ -1,1688 +1,1688 @@
1
- /**
2
- * Better Auth Configuration
3
- *
4
- * Primary auth configuration. Replaces the former NextAuth auth-options.ts.
5
- *
6
- * Architecture: No database adapter — Better Auth runs in stateless mode
7
- * with JWE cookie cache. User management stays on IDP, sessions on Redis.
8
- *
9
- * @see BETTER-AUTH-MIGRATION-SPEC.md
10
- */
11
-
12
- import 'server-only';
13
- import { AsyncLocalStorage } from 'node:async_hooks';
14
- import { createHash } from 'crypto';
15
- import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
16
- import { betterAuth } from 'better-auth';
17
- import { nextCookies } from 'better-auth/next-js';
18
- import { toNextJsHandler } from 'better-auth/next-js';
19
- import { wrapBetterAuthHandler } from './session-response-sanitizer';
20
- import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
21
- import { resolveClientKey } from '../lib/client-key';
22
- import { genericOAuth } from 'better-auth/plugins/generic-oauth';
23
- import {
24
- createIdpOAuthHooks,
25
- resolveIdpRedirectUri,
26
- type IdpTokenGrant,
27
- type IdpVerifiedClaims,
28
- } from './idp-oauth';
29
- import type { IDPClientConfig } from '../lib/idp-client-config';
30
- import { getIDPClientConfig } from '../lib/idp-client-config';
31
- import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
32
- import { getRedis } from '../lib/redis';
33
- import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
34
- import { SESSION_TTL } from '../lib/session-store';
35
- import { tokenLogId } from '../lib/token-log-id';
36
-
37
- /**
38
- * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
39
- * to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
40
- * = 48 bits; collision-tellable across a deployment, useless as a credential.
41
- */
42
- export function secretFingerprint(value: string): string {
43
- return createHash('sha256').update(value).digest('hex').slice(0, 12);
44
- }
45
-
46
- /**
47
- * 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
48
- * than Better Auth, which must survive a Better Auth session write.
49
- *
50
- * Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
51
- * list is a named constant rather than a hardcoded check because the shape of
52
- * the bug is "a second author appeared and the adapter did not know" — the next
53
- * enrichment key should be added here, not rediscovered as a strip.
54
- */
55
- const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
56
-
57
- /**
58
- * True for a JSON object we can safely merge into — not null, not an array.
59
- * `typeof null === 'object'` and arrays are objects, and merging into either
60
- * would corrupt the record rather than preserve it.
61
- */
62
- function isPlainObject(value: unknown): value is Record<string, unknown> {
63
- return typeof value === 'object' && value !== null && !Array.isArray(value);
64
- }
65
-
66
- /**
67
- * Better Auth social provider config shape.
68
- */
69
- export interface BetterAuthSocialProvider {
70
- clientId: string;
71
- clientSecret: string;
72
- scope?: string[];
73
- prompt?: string;
74
- accessType?: 'offline' | 'online';
75
- hd?: string;
76
- /**
77
- * 260419: better-auth's ONLY guard against OAuth auto-provisioning
78
- * (oauth2/link-account.mjs handleOAuthUserInfo: `if (disableSignUp) return
79
- * {error:"signup disabled"}`). Unset means ANY unknown Google identity gets a
80
- * local account created at callback. We wire it from the tenant's own policy.
81
- */
82
- disableSignUp?: boolean;
83
- }
84
-
85
- /**
86
- * Build Better Auth social providers from IDP config.
87
- */
88
- /**
89
- * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
90
- *
91
- * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
92
- * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
93
- * for the IDP to require a provider-signed id_token and verify it itself. This
94
- * is the client half: capture the token Better Auth already receives, and
95
- * forward it. Additive — the IDP ignores it until its gate flips.
96
- *
97
- * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
98
- * an id_token is credential material. Writing it to the session record would put
99
- * it one allow-list edit away from leaking out of /api/internal/session-data,
100
- * and it would outlive the request that needed it. ALS scopes it to the single
101
- * request, and it is gone when the request ends — nothing to expire, nothing to
102
- * scrub, nothing to leak.
103
- *
104
- * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
105
- * exchange (which reads it) both run inside createAuthGetHandler's own request.
106
- * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
107
- * that invokes getUserInfo — and the exchange follows at :465 in the SAME
108
- * function. Outside that scope the store is undefined and the body simply omits
109
- * id_token, which is the correct additive behaviour.
110
- */
111
- /**
112
- * ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
113
- * below) — and it was NOT, which is the whole defect.
114
- *
115
- * This module is loaded more than once per process: instrumentation and the auth route
116
- * are different chunks with their own module registries. `state` was moved to globalThis
117
- * so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
118
- * module scope, so each copy got its own.
119
- *
120
- * The result, measured on UMI 2026-08-26 against idp.payez.net:
121
- *
122
- * copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
123
- * copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
124
- * getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
125
- * the exchange reads B -> empty, so id_token is omitted from the body
126
- *
127
- * Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
128
- * The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
129
- * (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
130
- * has been shipping as a no-op wherever the module double-loads, and the security fix it
131
- * exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
132
- * combination: everything looks wired, and the one field that proves identity is dropped.
133
- *
134
- * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
135
- * carrier needs to be reachable from every copy that might run inside the request.
136
- */
137
- /**
138
- * THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
139
- * THINGS. Renaming it would give a copy on the old name and a copy on the new
140
- * name two DIFFERENT stores in the same process — which is precisely the
141
- * double-load failure documented above, reintroduced by a cosmetic rename. The
142
- * name is a wire contract between copies, not a description.
143
- *
144
- * accessToken is here for providers that do NOT issue an id_token. GitHub is
145
- * not an OIDC provider: it has no id_token, no JWKS and no discovery document,
146
- * so `id_token` can never be the field that proves a GitHub identity. The IDP
147
- * re-derives that identity by calling GitHub itself with this access token
148
- * (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
149
- * verifies for itself and never trusts an asserted email.
150
- */
151
- const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
152
- type ProviderTokenScope = {
153
- idToken?: string;
154
- accessToken?: string;
155
- /** PAY-2305: what the payez-idp getToken / getUserInfo hooks captured for THIS request (never persisted here). */
156
- idp?: { tokens?: IdpTokenGrant; claims?: IdpVerifiedClaims };
157
- };
158
- const gScope = globalThis as unknown as Record<
159
- string,
160
- AsyncLocalStorage<ProviderTokenScope> | undefined
161
- >;
162
- gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
163
- const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
164
-
165
- /**
166
- * EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
167
- * provider credentials in the app.
168
- *
169
- * ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
170
- * Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
171
- * is uniform with the ratified desktop leg. The IDP holds the provider secret
172
- * and performs the provider exchange server-side.
173
- *
174
- * ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
175
- * QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
176
- * clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
177
- * on whether we need a secret. This builds the generic/custom shape
178
- * deliberately; reaching for a preset because it looks tidier would silently
179
- * kill keyless-on-web and nobody would connect the two changes.
180
- *
181
- * ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
182
- * Returns null unless the IDP's OAuth endpoints are explicitly configured. That
183
- * makes this ADDITIVE: the existing socialProviders path keeps working until an
184
- * operator turns this on. It is NOT a runtime failover - nothing silently
185
- * chooses between them, because a silent chooser is how a migration ends up
186
- * half-applied and undetectable.
187
- *
188
- * ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
189
- * the app stops talking to Google, so it can no longer capture the raw
190
- * id_token that oauth-callback verifies today, and the per-provider settings
191
- * below stop being ours to set - profile scope, prompt=select_account,
192
- * accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
193
- * exchange must verify signature/iss/aud/exp itself and preserve those four, or
194
- * they are lost silently.
195
- */
196
- export interface IdpOAuthProviderConfig {
197
- providerId: string;
198
- discoveryUrl: string;
199
- clientId: string;
200
- pkce: true;
201
- scopes: string[];
202
- /** PAY-2305: the REGISTERED {site_url}/api/auth/callback, sent identically at authorize and token time. */
203
- redirectURI: string;
204
- getToken: ReturnType<typeof createIdpOAuthHooks>['getToken'];
205
- getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
206
- }
207
-
208
- export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null {
209
- const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
210
- const clientId = process.env.CLIENT_ID;
211
- if (!discoveryUrl || !clientId) return null;
212
-
213
- const redirectURI = resolveIdpRedirectUri(
214
- appBaseUrl || process.env.BETTER_AUTH_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || '3000'}`,
215
- );
216
- const hooks = createIdpOAuthHooks({
217
- discoveryUrl,
218
- clientId,
219
- redirectURI,
220
- issuer: process.env.IDP_OAUTH_ISSUER || undefined,
221
- trustedOrigins: process.env.IDP_OAUTH_TRUSTED_ORIGINS
222
- ? process.env.IDP_OAUTH_TRUSTED_ORIGINS.split(',').map((x) => x.trim()).filter(Boolean)
223
- : undefined,
224
- onTokens: (tokens) => {
225
- const store = idTokenScope.getStore();
226
- if (store) (store.idp ??= {}).tokens = tokens;
227
- },
228
- onClaims: (claims) => {
229
- const store = idTokenScope.getStore();
230
- if (store) (store.idp ??= {}).claims = claims;
231
- },
232
- });
233
-
234
- return {
235
- providerId: 'payez-idp',
236
- discoveryUrl,
237
- clientId,
238
- redirectURI,
239
- getToken: hooks.getToken,
240
- getUserInfo: hooks.getUserInfo,
241
- // NO clientSecret. The whole point: this app is not a credential holder.
242
- pkce: true,
243
- scopes: ['openid', 'email', 'profile'],
244
-
245
- /**
246
- * ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
247
- *
248
- * Found by QAPert-NightHawk (21348) — the second config trap in this plugin
249
- * after the preset one, and the same shape: a field that looks like a
250
- * harmless transport detail and quietly changes whether we are a public
251
- * client at all.
252
- *
253
- * @better-auth/core validate-authorization-code.mjs:
254
- * if (authentication === "basic") {
255
- * headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
256
- * } else {
257
- * body.set("client_id", clientId);
258
- * if (options.clientSecret) body.set("client_secret", options.clientSecret);
259
- * }
260
- *
261
- * Note what the 'basic' branch does with NO secret: it sends an EMPTY
262
- * password, `Basic base64("<clientId>:")`. That is not "no authentication",
263
- * it is WRONG authentication — we would be presenting ourselves as a
264
- * confidential client with a blank credential. The default branch is the
265
- * one that produces a genuine public-client PKCE request.
266
- *
267
- * Leaving this field UNSET is load-bearing, which is exactly why it needs a
268
- * comment: nothing about an absent field advertises that its absence is a
269
- * decision.
270
- */
271
- };
272
- }
273
-
274
- export function buildBetterAuthProviders(
275
- config: IDPClientConfig
276
- ): Record<string, BetterAuthSocialProvider> {
277
- const providers: Record<string, BetterAuthSocialProvider> = {};
278
-
279
- for (const oauth of config.oauthProviders || []) {
280
- if (!oauth.enabled) continue;
281
- const name = oauth.provider.toLowerCase();
282
- const additionalParams = oauth.additionalParams ?? {};
283
- const rawPrompt = additionalParams.prompt;
284
- const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
285
- const rawHostedDomain = additionalParams.hd;
286
-
287
- // Ensure profile scope is present for Google so avatar image is returned
288
- const scopes = oauth.scopes?.split(' ') || [];
289
- if (name === 'google' && !scopes.includes('profile')) {
290
- scopes.push('profile');
291
- }
292
-
293
- /**
294
- * 224721: better-auth's google provider UNCONDITIONALLY seeds its default
295
- * scopes ['email','profile','openid'] and then pushes our `scope` verbatim
296
- * (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
297
- * the row's "openid email profile" reached Google as
298
- * "email profile openid openid email profile". Semantically harmless
299
- * (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
300
- * only what the defaults do not already cover; undefined when nothing
301
- * remains, so the provider emits exactly its default set.
302
- */
303
- const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
304
- /**
305
- * Same defect, same fix, different provider: github's createAuthorizationURL
306
- * also seeds ['read:user','user:email'] unconditionally and then pushes ours
307
- * on top (@better-auth/core social-providers/github.ts), so the stored row
308
- * "read:user user:email" reached GitHub as
309
- * "read:user user:email read:user user:email". Measured on :4799, not
310
- * assumed. GitHub dedupes, so this is legibility rather than function — but
311
- * a scope list is the first thing a security reviewer reads off that URL.
312
- */
313
- const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
314
- const wireScopes =
315
- name === 'google'
316
- ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
317
- : name === 'github'
318
- ? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
319
- : scopes;
320
-
321
- providers[name] = {
322
- clientId: oauth.clientId,
323
- clientSecret: oauth.clientSecret,
324
- scope: wireScopes.length > 0 ? wireScopes : undefined,
325
- /**
326
- * PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
327
- *
328
- * 260419 wired this from `config.authSettings?.allowPublicRegistration` to
329
- * close a client-side self-serve provisioning door. It cannot do that job
330
- * correctly, and it BREAKS SIGN-IN:
331
- *
332
- * - This app runs better-auth in STATELESS mode (no DB adapter — see the
333
- * file header). better-auth therefore has NO user store, so at the OAuth
334
- * callback it cannot tell an EXISTING user from a NEW one. Its
335
- * handleOAuthUserInfo consults `disableSignUp` for BOTH, so
336
- * `disableSignUp: true` refuses a returning user exactly like a
337
- * stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
338
- * Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
339
- * once client 12's allow_public_registration reported false.
340
- *
341
- * - The REAL registration gate already exists and is correct, at the IDP:
342
- * PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
343
- * user up by verified email (:799); an existing user logs in UNTOUCHED
344
- * (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
345
- * which refuses fail-closed when the client forbids public registration
346
- * (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
347
- * Its own comment states the contract: "existing federated users ...
348
- * log in untouched, so gating here never blocks login."
349
- *
350
- * So this flag was a DUPLICATE gate that could only ever misfire — it cannot
351
- * distinguish new from existing, and the IDP already refuses new identities.
352
- * Removing it restores sign-in for existing users WITHOUT reopening
353
- * self-serve: a NEW identity that completes the better-auth leg still gets
354
- * REFUSED at the IDP exchange and never receives IDP tokens, which is the
355
- * portal's actual authorization gate.
356
- *
357
- * WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
358
- * (above) as the backstop, and each app's own front-door sign-up gate
359
- * (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
360
- * Both are correct and neither is this flag.
361
- */
362
- // Google is overly eager to reuse the last account unless we
363
- // explicitly ask for account selection on each social login.
364
- prompt: typeof rawPrompt === 'string'
365
- ? rawPrompt
366
- : name === 'google'
367
- ? 'select_account'
368
- : undefined,
369
- accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
370
- hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
371
- };
372
-
373
- /**
374
- * CAPTURE THE RAW id_token — card 138635, client half.
375
- *
376
- * getUserInfo is the ONLY provider hook that receives the token set.
377
- * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
378
- * signed JWT cannot be reconstructed from them. We need the raw token
379
- * because the IDP must verify the signature ITSELF — a client-decoded
380
- * claim is just another assertable field, which is the defect restated.
381
- *
382
- * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
383
- * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
384
- * returns our result verbatim, so everything the default did is now ours to
385
- * do — including the `hd` hosted-domain restriction.
386
- *
387
- * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
388
- * above, so a client can restrict sign-in to a Google Workspace domain.
389
- * Silently dropping it would let ANY Google account into a client that had
390
- * restricted itself — a security regression introduced by a security fix.
391
- * (Measured on 93: zero clients set it today. That is a reason to be
392
- * careful, not a reason to skip it — it is configurable and unused, which
393
- * is exactly the state in which a regression goes unnoticed.)
394
- *
395
- * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
396
- * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
397
- * `if (!configuredHostedDomain) return true`. With no hd configured the
398
- * default would return true unconditionally, so omitting it is equivalent
399
- * by the library's own definition rather than by our judgement.
400
- *
401
- * A client that DOES set hd keeps the stock provider, keeps its domain
402
- * restriction, and simply does not contribute an id_token yet. Losing a
403
- * capability is an acceptable cost; silently losing a security control is
404
- * not. (better-auth does not re-export the predicate through its public
405
- * types, and reimplementing a security check is how two copies drift — so
406
- * the honest move is to not need it.)
407
- */
408
- if (name === 'google' && !providers[name].hd) {
409
- (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
410
- if (!token?.idToken) return null;
411
-
412
- // The only new behaviour: hand the raw token to this request's scope.
413
- const scope = idTokenScope.getStore();
414
- if (scope) {
415
- scope.idToken = token.idToken;
416
- } else {
417
- // NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
418
- // the login still succeeds, and the IDP rejects the exchange with
419
- // reason="id_token missing" a second later, with nothing connecting the two.
420
- // That cost a day on UMI. If this fires, the OAuth callback is running outside
421
- // createAuthGetHandler — either the route mounts the bare
422
- // `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
423
- // module has double-loaded and the scope is not shared (see 181814 above).
424
- console.warn(
425
- '[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
426
- 'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
427
- 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
428
- );
429
- }
430
-
431
- // Mirrors the upstream default (google.mjs) verbatim, minus the hd
432
- // branch that cannot fire here — so overriding the hook changes WHAT WE
433
- // KEEP, never what we ACCEPT.
434
- const claims = decodeJwt(token.idToken) as Record<string, any>;
435
- return {
436
- user: {
437
- id: claims.sub,
438
- name: claims.name,
439
- email: claims.email,
440
- image: claims.picture,
441
- emailVerified: claims.email_verified,
442
- },
443
- data: claims,
444
- };
445
- };
446
- }
447
-
448
- /**
449
- * GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
450
- *
451
- * GitHub is not an OIDC provider — it issues no id_token — so the google
452
- * branch above can never fire for it and the exchange would post an
453
- * assertion with nothing that proves identity. That is exactly the
454
- * 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
455
- * `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
456
- * the IDP rejected it instantly, without ever calling GitHub.
457
- *
458
- * getUserInfo is the ONLY provider hook that receives the token set (see the
459
- * google note above), and @better-auth/core's github provider calls
460
- * `options.getUserInfo(token)` BEFORE its own body — so installing this
461
- * REPLACES the default rather than wrapping it, and the default's work has
462
- * to be reproduced here or the sign-in loses its profile entirely.
463
- *
464
- * Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
465
- * the version the container actually runs (better-auth 1.5.6 — the graft's
466
- * node_modules says 1.6.29 and is NOT what executes). Kept deliberately
467
- * dumb: it must not "improve" on upstream, because every divergence here is
468
- * a behaviour change disguised as a token capture.
469
- *
470
- * This is NOT the identity check. The IDP re-derives the GitHub identity
471
- * itself from this access token and enforces primary && verified server-side
472
- * (GitHubIdentityVerifier); what we return here only shapes the local
473
- * better-auth user record.
474
- */
475
- if (name === 'github') {
476
- (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
477
- accessToken?: string;
478
- }) => {
479
- const store = idTokenScope.getStore();
480
- if (store) {
481
- store.accessToken = token?.accessToken;
482
- } else {
483
- console.warn(
484
- '[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
485
- 'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
486
- 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
487
- );
488
- }
489
-
490
- const ghHeaders = {
491
- 'User-Agent': 'better-auth',
492
- Authorization: `Bearer ${token?.accessToken}`,
493
- };
494
-
495
- const profileRes = await fetch('https://api.github.com/user', {
496
- headers: ghHeaders,
497
- });
498
- // Upstream returns null on profile error, which fails the sign-in
499
- // closed. Preserved rather than softened.
500
- if (!profileRes.ok) {
501
- console.warn(
502
- `[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
503
- );
504
- return null;
505
- }
506
- const profile = (await profileRes.json()) as Record<string, any>;
507
-
508
- const emailsRes = await fetch('https://api.github.com/user/emails', {
509
- headers: ghHeaders,
510
- });
511
- const emails = emailsRes.ok
512
- ? ((await emailsRes.json()) as Array<{
513
- email: string;
514
- primary: boolean;
515
- verified: boolean;
516
- }>)
517
- : undefined;
518
-
519
- // A GitHub account with a private email returns profile.email = null;
520
- // upstream falls back to the primary address here.
521
- if (!profile.email && emails) {
522
- profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
523
- }
524
- const emailVerified =
525
- emails?.find((e) => e.email === profile.email)?.verified ?? false;
526
-
527
- return {
528
- user: {
529
- id: profile.id,
530
- name: profile.name || profile.login || '',
531
- email: profile.email,
532
- image: profile.avatar_url,
533
- emailVerified,
534
- },
535
- data: profile,
536
- };
537
- };
538
- }
539
- }
540
-
541
- return providers;
542
- }
543
-
544
- /**
545
- * Optional configuration for `createBetterAuthInstance`.
546
- *
547
- * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
548
- * The host app supplies its own `sendMagicLink` callback — typically a
549
- * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
550
- * Omit the `magicLink` key entirely to skip the plugin; the consuming
551
- * app will not have a magic-link flow.
552
- */
553
- export interface CreateBetterAuthInstanceOptions {
554
- magicLink?: MagicLinkOptions;
555
- /**
556
- * Extra Better Auth plugins supplied by the consuming app.
557
- *
558
- * WHY THIS EXISTS — session minting is not delegable any other way. BA's
559
- * `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
560
- * endpoint machinery constructs, so an app that must mint a session for an
561
- * identity established OUTSIDE BA's built-in providers (passkey sign-in being
562
- * the first) cannot do it from a route handler. It needs a real BA endpoint,
563
- * and endpoints only arrive via plugins.
564
- *
565
- * Deliberately GENERIC. The package gains "an app may supply plugins" rather
566
- * than any knowledge of passkeys — the alternative was teaching this file
567
- * about a specific auth method, which is how a package accumulates a consumer's
568
- * feature list.
569
- *
570
- * The alternative to this option is an app hand-rolling the session cookie
571
- * (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
572
- * Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
573
- * so this option exists specifically to make that shortcut unnecessary.
574
- */
575
- plugins?: Parameters<typeof betterAuth>[0]['plugins'];
576
- }
577
-
578
- /**
579
- * Create Better Auth instance from IDP config.
580
- *
581
- * No database — runs in stateless mode with JWE cookie cache.
582
- * Call after getIDPClientConfig() resolves.
583
- */
584
- export function createBetterAuthInstance(
585
- idpConfig: IDPClientConfig,
586
- opts: CreateBetterAuthInstanceOptions = {}
587
- ) {
588
- assertAppSlugMatchesIdp(idpConfig.clientSlug);
589
- const appSlug = idpConfig.clientSlug || getAppSlug();
590
-
591
- // 185121 line 1: name the secret this instance PINNED, as a hash, never the
592
- // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
593
- // caused by a divergent secret is only diagnosable if every instance
594
- // creation logged WHICH secret it bound. Both init paths (this module's
595
- // getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
596
- // here, so this one line covers both; getAuthInstance adds its own line
597
- // naming the path, because it previously created an instance INVISIBLY.
598
- console.log(
599
- `[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret as string)}` : 'ABSENT'}`
600
- );
601
-
602
- // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
603
- // Must include /api/auth since that's where the catch-all route is mounted
604
- const rawBaseURL = process.env.BETTER_AUTH_URL
605
- || idpConfig.baseClientUrl
606
- || `http://localhost:${process.env.PORT || '3000'}`;
607
- const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
608
-
609
- const idpOAuth = buildIdpOAuthProviderConfig(rawBaseURL);
610
-
611
- return betterAuth({
612
- baseURL,
613
- secret: idpConfig.authSecret as string,
614
-
615
- socialProviders: buildBetterAuthProviders(idpConfig),
616
-
617
- // Trust the app's own origin + any configured base URL
618
- trustedOrigins: [
619
- rawBaseURL,
620
- baseURL,
621
- ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
622
- // MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
623
- // and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
624
- // BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
625
- // returned 400, because the port it actually runs on was not one of the three
626
- // someone had typed in. A config-shaped constant is not configuration.
627
- //
628
- // The app's own origin is already covered by rawBaseURL/baseURL above; the dev
629
- // origin is DERIVED from PORT, and anything genuinely external comes from
630
- // BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
631
- `http://localhost:${process.env.PORT || '3000'}`,
632
- ...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
633
- ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
634
- : []),
635
- ],
636
-
637
- // Redis-backed session storage via secondaryStorage
638
- secondaryStorage: {
639
- /**
640
- * 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
641
- * swallow a Redis failure into `null` here.
642
- *
643
- * `null` from `get` is Better Auth's OWN signal for "no session at this
644
- * key" (see node_modules/better-auth db/internal-adapter.mjs
645
- * `findSession`: `if (!sessionStringified ...) return null`). Catching
646
- * a Redis error and returning `null` therefore told better-auth "this
647
- * session does not exist" when the true state was "the store could not
648
- * be asked" — indistinguishable from a genuine sign-out. With
649
- * `cookieCache` disabled (session.cookieCache.enabled above), EVERY
650
- * `getSession()` call hits this path for real, so a Redis blip
651
- * manufactured a `200 { session: null }` for every live session at
652
- * once — the exact channel 183483's client-side guard (verify-code
653
- * page's session-liveness check) was built to survive, and the one it
654
- * could not survive while this swallow stood: a fabricated "no
655
- * session" 200 is not distinguishable from a real one by any client.
656
- *
657
- * Letting the error propagate is safe: better-auth's own router
658
- * (better-call/dist/router.mjs) wraps every endpoint handler in a
659
- * top-level try/catch and converts an uncaught throw to a genuine
660
- * `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
661
- * corrected client guard already knows how to hold a session through —
662
- * so a Redis outage now surfaces as "couldn't check" instead of
663
- * silently lying "confirmed logged out".
664
- */
665
- get: async (key: string) => {
666
- const value = await getRedis().get(`ba:${appSlug}:${key}`);
667
- // 185121 line 2: the 183047 discriminator. A null here means
668
- // RECORD-MISS — the store was reachable and the key is absent. If the
669
- // record IS present but BA still rejects the session, this line does
670
- // NOT fire and the failure is upstream of storage (HMAC/secret gate).
671
- // BA-primary logs nothing on failure, so without this line the two
672
- // surviving forks are indistinguishable in the artifact. Key is
673
- // fingerprinted: it embeds the session token.
674
- if (value === null) {
675
- console.warn(
676
- `[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`
677
- );
678
- }
679
- return value;
680
- },
681
- /**
682
- * 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
683
- * PERSISTED IS NOT A SESSION.
684
- *
685
- * This previously caught every Redis error and returned normally, so
686
- * better-auth was told the session had been stored when nothing had been
687
- * written. The user got a successful login, a session cookie, and a
688
- * rendered signed-in shell on top of a store that holds no session — and
689
- * the very next `getSession()` reads `null` and signs them straight back
690
- * out. Nothing logged, nothing thrown, no banner: a login that "worked"
691
- * and a sign-out nobody can explain.
692
- *
693
- * Letting it propagate is the same contract `get` above already relies on:
694
- * better-auth's router (better-call/dist/router.mjs) wraps every endpoint
695
- * handler and converts an uncaught throw into a genuine 500. A 500 on the
696
- * login write is honest — "we could not store your session" — and it is
697
- * distinguishable by any client from a successful login, which a swallowed
698
- * failure is not.
699
- *
700
- * THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
701
- * works". It does not: `session.cookieCache.enabled` is FALSE (see below).
702
- * The comment described a safety net that had been removed, which is why
703
- * this swallow looked survivable to every reader who trusted it (170033
704
- * fix 3).
705
- */
706
- set: async (key: string, value: string, ttl?: number) => {
707
- const redis = getRedis();
708
- const redisKey = `ba:${appSlug}:${key}`;
709
-
710
- /**
711
- * 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
712
- * (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
713
- * evidence under BAPert's R920(a). Reproduced here before fixing.)
714
- *
715
- * Better Auth owns `{session, user}` and serialises exactly that. But
716
- * `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
717
- * `idpTokens` via its own read-modify-write. This setter did a blind
718
- * `setex` of Better Auth's payload, so every write it makes — including
719
- * each routine `updateAge` session rewrite, not merely an expiry —
720
- * STRIPPED `idpTokens` back off the record.
721
- *
722
- * The consumer then falls back to an identity husk: `mfaVerified:false`,
723
- * `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
724
- * per BAPert it is a public-test blocker on its own terms, because a
725
- * tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
726
- * AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
727
- * failure regardless of which direction it fails in.
728
- *
729
- * WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
730
- * does a read-modify-write; it was correct. The bug is that ONE of the
731
- * two authors of this key wrote as though it were the only one. Fixing
732
- * the adapter fixes it for every future enrichment key, whereas moving
733
- * `idpTokens` elsewhere would leave the next one to rediscover this.
734
- *
735
- * ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
736
- * payload does not set them — so Better Auth still wins on everything it
737
- * owns, and an explicit incoming value is never overridden by a stale one.
738
- *
739
- * NOT ATOMIC, and deliberately not: an enrichment landing between this
740
- * read and this write is still lost. Closing that needs a WATCH/MULTI or
741
- * a Lua script, which is a larger change than tonight allows. This
742
- * narrows the window from "every single write" to "a genuine race", and
743
- * the residual is recorded here rather than left to be re-found.
744
- */
745
- let payload = value;
746
- try {
747
- const existingRaw = await redis.get(redisKey);
748
- if (existingRaw) {
749
- const existing = JSON.parse(existingRaw);
750
- const incoming = JSON.parse(value);
751
- if (isPlainObject(existing) && isPlainObject(incoming)) {
752
- let carried = false;
753
- for (const preserved of PRESERVED_SESSION_KEYS) {
754
- if (preserved in existing && !(preserved in incoming)) {
755
- incoming[preserved] = existing[preserved];
756
- carried = true;
757
- }
758
- }
759
- if (carried) payload = JSON.stringify(incoming);
760
- }
761
- }
762
- } catch (mergeErr) {
763
- /**
764
- * This adapter also stores `verification:*` and `active-sessions-*`
765
- * records, which are not necessarily session-shaped JSON. A record we
766
- * cannot parse is not a session to enrich, so writing through unchanged
767
- * is correct rather than merely tolerable.
768
- *
769
- * WARNED, NOT THROWN, and the asymmetry is the point: this block is an
770
- * ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
771
- * degraded read into a lost session — the precise inversion 170033 fix 1
772
- * above exists to prevent. The `setex` below is outside this try and is
773
- * still unswallowed, so a genuine write failure still propagates.
774
- */
775
- console.warn(
776
- `[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
777
- `ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
778
- `as-is. If this record held idpTokens they are now gone (273183):`,
779
- mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
780
- );
781
- }
782
-
783
- /**
784
- * QA 6952 / R913 - CLAMP, DO NOT DEFER.
785
- *
786
- * This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
787
- * source of session TTL" - what session-store.ts claims, and the mechanism
788
- * by which the 7d-vs-3d zombie window was supposedly closed for good.
789
- *
790
- * IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
791
- * eight secondaryStorage.set call sites in
792
- * better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
793
- * :180, :189, :309, :320, :344, :553, :622). `??` fires only on
794
- * null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
795
- * Auth's 7d default - `session.expiresIn` is not configured in this file -
796
- * while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
797
- * the zombie window the constant exists to prevent: the guard was bypassed
798
- * by the very parameter written to defer to it.
799
- *
800
- * `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
801
- * `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
802
- * records whose TTLs are legitimately SHORTER than 3d. Overwriting those
803
- * would EXTEND them - turning a fix for over-long sessions into a bug that
804
- * keeps verification codes alive for three days. Clamp DOWN, never up.
805
- *
806
- * Clamped down to 3d rather than raising the canonical store to 7d because
807
- * SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
808
- * the refresh token sustaining it cannot be refreshed, so the extra days are
809
- * a husk that reads as authenticated. Fail-closed beats ambiguous.
810
- *
811
- * DO NOT "fix" this by making storeIdpTokensInSession preserve Better
812
- * Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
813
- * universal. See test/ba-session-ttl-clamp.test.cjs.
814
- */
815
- await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
816
- },
817
- delete: async (key: string) => {
818
- try {
819
- await getRedis().del(`ba:${appSlug}:${key}`);
820
- } catch (err) {
821
- /**
822
- * 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
823
- *
824
- * Deliberately logged rather than thrown, and the distinction is not
825
- * timidity: the CANONICAL store is what decides session liveness (see
826
- * 181922 below), and its delete runs immediately after this one and is
827
- * NOT swallowed. So a BA record that outlives its canonical twin does
828
- * not authenticate anyone — throwing here would fail a sign-out that
829
- * actually succeeded where it counts.
830
- *
831
- * What was unacceptable is that it happened SILENTLY. A revocation
832
- * that half-failed left no trace anywhere, so the leftover key could
833
- * only ever be found by someone already reading the keyspace for
834
- * another reason. `error`, not `warn`: this is a revocation that did
835
- * not complete, not a retryable blip.
836
- */
837
- console.error(
838
- `[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
839
- `the better-auth session record may still exist. Canonical delete ` +
840
- `below is what governs liveness; if it also failed, the session is ` +
841
- `NOT revoked:`,
842
- err
843
- );
844
- }
845
- /**
846
- * 181922 — teardown is BOTH stores. BA's sign-out deletes only its
847
- * secondary record; the canonical {slug}:sess:{token} would survive
848
- * and keep the session alive (a replayed cookie still authenticates).
849
- * `key` here IS the session token (the ba: prefix is applied above),
850
- * so the canonical delete is one call. This also fires on session
851
- * rotation/expiry deletions, which is correct: a session BA drops
852
- * must not live on in the canonical store.
853
- */
854
- try {
855
- const { deleteSession } = await import('../lib/session-store');
856
- await deleteSession(key);
857
- } catch (err) {
858
- console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
859
- }
860
- },
861
- },
862
-
863
- session: {
864
- // Cookie cache DISABLED — Redis is canonical for session liveness.
865
- //
866
- // Previously: enabled with maxAge 300 + refreshCache false. That cached
867
- // a decoded session in process memory for 5 minutes, bypassing Redis
868
- // reads during the window. Consequence: if the canonical app session
869
- // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
870
- // Better Auth's in-memory copy stayed alive — and callers got
871
- // contradictory answers depending on whether they consulted Better
872
- // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
873
- // visible in production traces: viability 200 + idp-token 200 +
874
- // getFreshIdpToken NO_SESSION terminal within milliseconds.
875
- //
876
- // Trade-off: every Better Auth getSession() call now incurs one Redis
877
- // read on the secondary-storage path. Latency cost is acceptable
878
- // (sub-ms in-region) and the alternative is duplicate Layer-1
879
- // workarounds in every consumer app — already shipped in idealvibe.online
880
- // at b6a91f6.
881
- cookieCache: {
882
- enabled: false,
883
- maxAge: 300,
884
- refreshCache: false,
885
- },
886
- },
887
-
888
- // PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
889
- // 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
890
- // database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
891
- // : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
892
- // OAuth state record into Redis through the adapter above (its enrichment read-modify-write
893
- // and key handling were built for session records, not state). Pinned so the upgrade changes
894
- // the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
895
- // compares the returned `state` with the nonce sealed in the cookie, and our provider is also
896
- // pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
897
- account: {
898
- storeStateStrategy: 'cookie',
899
- },
900
-
901
- // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
902
- advanced: {
903
- cookiePrefix: appSlug,
904
- cookies: {
905
- session_token: {
906
- name: `${appSlug}.session-token`,
907
- },
908
- },
909
- },
910
-
911
- plugins: [
912
- nextCookies(),
913
- // Exchange-move: present ONLY when the IDP's OAuth endpoints are
914
- // configured. Absent otherwise, so this cannot alter existing behaviour.
915
- ...(idpOAuth ? [genericOAuth({ config: [idpOAuth] })] : []),
916
- ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
917
- // App-supplied plugins last: they may depend on the built-ins above, and
918
- // ordering is BA's own concern once they are in the array.
919
- ...(opts.plugins ?? []),
920
- ],
921
- });
922
- }
923
-
924
- /**
925
- * Better Auth is always enabled (NextAuth removed in 4.0).
926
- */
927
- export function isBetterAuthEnabled(): boolean {
928
- return true;
929
- }
930
-
931
- /**
932
- * Get Better Auth Next.js route handlers (GET, POST).
933
- * Initializes Better Auth from IDP config on first call, caches the instance.
934
- *
935
- * STATE LIVES ON globalThis, NOT IN MODULE SCOPE (181814, measured 2026-08-07):
936
- * under Turbopack, instrumentation.ts and the /api/auth/[...all] route are
937
- * different chunks and each loads its OWN copy of this module. Module-level
938
- * `configuredOpts` set by configureBetterAuth() in instrumentation never
939
- * reached the copy getBetterAuthInstance() read in the route — measured
940
- * identity probe: sameInstance=false, route-side plugins=["next-cookies"]
941
- * while the instrumentation-side instance carried every configured plugin.
942
- * No throw anywhere (the double-init guard never sees the other copy), core
943
- * auth green, plugin routes 404. globalThis is per-process, per-runtime —
944
- * exactly the scope this state must have.
945
- */
946
- interface BetterAuthModuleState {
947
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
948
- cachedInstance: any;
949
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
950
- initPromise: Promise<any> | null;
951
- // PAY-642: the init attempt, single-flighted so a REJECTED attempt clears itself (see getBetterAuthInstance).
952
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
953
- initFlight?: SingleFlight<any>;
954
- configuredOpts: CreateBetterAuthInstanceOptions;
955
- }
956
-
957
- const STATE_KEY = '__payezBetterAuthState';
958
- const g = globalThis as unknown as Record<string, BetterAuthModuleState | undefined>;
959
- g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
960
- const state = g[STATE_KEY];
961
-
962
- // Expose for server-side session access (decode-session.ts). A getter so the
963
- // export tracks the SHARED state, not a chunk-local snapshot.
964
- export function __getBetterAuthInstance(): unknown {
965
- return state.cachedInstance;
966
- }
967
-
968
- /**
969
- * Configure Better Auth instance options for this process.
970
- *
971
- * Must be called before the first auth request — before
972
- * `getBetterAuthInstance()` caches an instance. Typically called once at
973
- * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
974
- * server bootstrap hook.
975
- *
976
- * Throws if called after the instance has already been resolved: options
977
- * cannot be applied retroactively.
978
- */
979
- export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
980
- if (state.cachedInstance) {
981
- throw new Error(
982
- '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
983
- 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
984
- );
985
- }
986
- state.configuredOpts = opts;
987
- }
988
-
989
- /**
990
- * PAY-642 (card 338215): the init attempt no longer LATCHES A FAILURE.
991
- *
992
- * This used to be `state.initPromise ??= getIDPClientConfig(true).then(...)` with no .catch and no reset. One
993
- * auth request against a cold process while the IdP was unreachable stored a rejected promise, and every later
994
- * caller replayed it until the process restarted, even after the IdP recovered. This funnel backs every
995
- * /api/auth/* route, the login GET handler and withAuth. server/auth.ts getAuthInstance() already used
996
- * createSingleFlight for this exact bug; this sibling funnel was missed.
997
- *
998
- * Now the attempt runs through the same helper: concurrent callers share one attempt, a failure still rejects
999
- * for every caller of THAT attempt (loud), the next call starts a fresh attempt, and a success is cached in
1000
- * state.cachedInstance as before. The flight lives on the globalThis state for the same reason the instance
1001
- * does (duplicated route chunks must share one init).
1002
- */
1003
- export async function getBetterAuthInstance() {
1004
- if (state.cachedInstance) return state.cachedInstance;
1005
-
1006
- state.initFlight ??= createSingleFlight(() =>
1007
- getIDPClientConfig(true).then(config => {
1008
- const instance = createBetterAuthInstance(config, state.configuredOpts);
1009
- state.cachedInstance = instance;
1010
- console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
1011
- return instance;
1012
- })
1013
- );
1014
-
1015
- const attempt = state.initFlight.get();
1016
- // Kept for callers that inspect it; it always points at the CURRENT attempt, never a settled failure.
1017
- state.initPromise = attempt;
1018
- return attempt;
1019
- }
1020
-
1021
- /**
1022
- * Get flag-gated auth handler for Next.js route.
1023
- *
1024
- * When USE_BETTER_AUTH=true, returns Better Auth handlers.
1025
- * Otherwise returns null (auth disabled).
1026
- *
1027
- * Usage in host app route:
1028
- * ```ts
1029
- * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
1030
- *
1031
- * export async function GET(req: Request) {
1032
- * const ba = await getBetterAuthHandler();
1033
- * if (ba) return ba.GET(req);
1034
- * }
1035
- * ```
1036
- */
1037
- export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
1038
- if (!isBetterAuthEnabled()) return null;
1039
-
1040
- const auth = await getBetterAuthInstance();
1041
- // PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
1042
- // token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
1043
- // auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
1044
- return wrapBetterAuthHandler(auth as any, toNextJsHandler(auth));
1045
- }
1046
-
1047
- /**
1048
- * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
1049
- *
1050
- * Call this from the OAuth callback route AFTER better-auth has processed the
1051
- * callback and created the session. Reads the session token from the Set-Cookie
1052
- * header of the response to find the BA Redis key.
1053
- *
1054
- * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
1055
- *
1056
- * RETURNS the IdP's own error code on an IDP refusal, or null otherwise
1057
- * (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
1058
- * boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
1059
- * blip and every cause collapsed to one generic login-page code — a NEW user
1060
- * refused registration bounced silently, which is the failure Jon named.
1061
- */
1062
- export async function exchangeOAuthForIdpTokens(
1063
- sessionToken: string,
1064
- provider: string = 'google'
1065
- ): Promise<boolean | string> {
1066
- try {
1067
- const config = await getIDPClientConfig();
1068
- const appSlug = config.clientSlug || getAppSlug();
1069
- const baKey = `ba:${appSlug}:${sessionToken}`;
1070
-
1071
- // Read the BA session from Redis
1072
- const baRaw = await getRedis().get(baKey).catch(() => null);
1073
- if (!baRaw) {
1074
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', tokenLogId(sessionToken));
1075
- return false;
1076
- }
1077
-
1078
- const baData = JSON.parse(baRaw);
1079
- const email = baData?.user?.email;
1080
- const name = baData?.user?.name;
1081
- const image = baData?.user?.image;
1082
- const baUserId = baData?.session?.userId || baData?.user?.id;
1083
-
1084
- if (!email) {
1085
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
1086
- return false;
1087
- }
1088
-
1089
- // Call IDP oauth-callback
1090
- const idpUrl = process.env.IDP_URL || '';
1091
- if (!idpUrl) {
1092
- console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
1093
- return false;
1094
- }
1095
-
1096
- console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
1097
-
1098
- const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
1099
- method: 'POST',
1100
- headers: { 'Content-Type': 'application/json' },
1101
- body: JSON.stringify({
1102
- provider,
1103
- provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
1104
- email,
1105
- name,
1106
- image,
1107
- client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? '',
1108
- /**
1109
- * Card 138635 — the RAW provider id_token, forwarded verbatim.
1110
- *
1111
- * Every other field in this body is an ASSERTION. The endpoint mints an
1112
- * IDP bearer from them today with no cryptographic check of any kind,
1113
- * which is why it is a complete authentication bypass and why it has
1114
- * been publicly reachable for 488 days.
1115
- *
1116
- * This field is the only one the IDP can VERIFY: signature against
1117
- * Google's JWKS, plus iss / exp / aud, and then read email_verified
1118
- * from the verified claims. Additive — the IDP ignores it until its
1119
- * gate flips, so this cannot break a login on its own.
1120
- *
1121
- * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
1122
- * decoded email_verified. A client-computed verification flag is a body
1123
- * field with a better story: an attacker posts email_verified:true and
1124
- * the defect is rebuilt one layer up. The IDP verifies for itself or
1125
- * the fix is theatre.
1126
- *
1127
- * Absent when this runs outside the OAuth callback scope — the key is
1128
- * simply omitted rather than sent null, so "we never captured one" stays
1129
- * distinguishable from "the provider issued none".
1130
- */
1131
- ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
1132
- /**
1133
- * The same field for providers that have no id_token to give.
1134
- *
1135
- * Identical doctrine to the id_token above: RAW, forwarded verbatim,
1136
- * NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
1137
- * "never captured" stays distinguishable from "provider issued none".
1138
- * The IDP verifies for itself by calling GitHub with this token and
1139
- * enforcing primary && verified; we assert nothing about the identity.
1140
- *
1141
- * Scoped to the provider access token captured during THIS callback, so
1142
- * a google login continues to send id_token only and is unaffected.
1143
- */
1144
- ...(idTokenScope.getStore()?.accessToken
1145
- ? { access_token: idTokenScope.getStore()!.accessToken }
1146
- : {}),
1147
- }),
1148
- });
1149
-
1150
- const oauthResText = await oauthRes.text();
1151
- // PAY-1427 (QAPert, PR 154 thread 326): this printed the first 500 chars of the oauth-callback TOKEN
1152
- // envelope on EVERY successful OAuth login (it runs before the !ok check). Status + length only.
1153
- console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
1154
-
1155
- if (!oauthRes.ok) {
1156
- console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
1157
-
1158
- /**
1159
- * PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
1160
- *
1161
- * Every refusal used to return a bare `false`, so the abort collapsed ALL
1162
- * causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
1163
- * registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
1164
- * bounced silently, indistinguishable from a network blip. Jon named the
1165
- * clear message explicitly (67856): "(b) a brand-new Google user with
1166
- * registration OFF is refused by the IDP with a clear message, not a
1167
- * silent bounce."
1168
- *
1169
- * The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
1170
- * We read ONLY `error.code` and return it VERBATIM — the abort puts it in
1171
- * the `?error=` param, and the HOST's allowlist decides whether to render
1172
- * it. The package does NOT interpret the code and does NOT add a message
1173
- * (that would be a second allowlist). No code in the body -> null (the
1174
- * generic host code), exactly as before.
1175
- */
1176
- let idpErrorCode: string | null = null;
1177
- try {
1178
- const parsed = JSON.parse(oauthResText);
1179
- // FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
1180
- // unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
1181
- if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
1182
- idpErrorCode = parsed.error.code;
1183
- }
1184
- } catch {
1185
- /* non-JSON refusal body -> no code (generic abort) */
1186
- }
1187
-
1188
- /**
1189
- * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
1190
- *
1191
- * UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
1192
- * them apart: we sent no id_token for the IDP to verify, or we sent one whose
1193
- * signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
1194
- * a login that visibly succeeded and has nowhere to go — which is where UMI sat on
1195
- * 2026-08-26.
1196
- *
1197
- * NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
1198
- * path exists to keep it out of anything persistent; a log file is persistent. We
1199
- * print presence, length, and the three claims that decide verification. `aud` is
1200
- * the Google client id — not a secret, and the single most likely mismatch, since
1201
- * the IDP checks it against what IT has registered for this client.
1202
- */
1203
- const forwarded = idTokenScope.getStore()?.idToken;
1204
- let claims: Record<string, unknown> = {};
1205
- if (forwarded) {
1206
- try {
1207
- claims = decodeJwt(forwarded) as Record<string, unknown>;
1208
- } catch {
1209
- claims = { decode: 'FAILED — not a well-formed JWT' };
1210
- }
1211
- }
1212
- const forwardedAccess = idTokenScope.getStore()?.accessToken;
1213
- console.error('[BETTER_AUTH] exchange diagnostics:', {
1214
- id_token_forwarded: !!forwarded,
1215
- id_token_length: forwarded ? forwarded.length : 0,
1216
- /**
1217
- * Read these two for github, and IGNORE the id_token pair above: github
1218
- * issues no id_token, so `id_token_forwarded: false` is its NORMAL state
1219
- * and not the fault. Presence and length only — an access token is
1220
- * credential material and this log is persistent. A github failure with
1221
- * access_token_forwarded:true means the IDP called GitHub and refused
1222
- * the answer (unverified or non-primary email, or app not installed);
1223
- * false means the capture hook never ran and the IDP never called out.
1224
- */
1225
- access_token_forwarded: !!forwardedAccess,
1226
- access_token_length: forwardedAccess ? forwardedAccess.length : 0,
1227
- // Absent id_token => the IDP had nothing to verify. Present => compare these
1228
- // against the client's registered Google credentials on the IDP.
1229
- aud: claims.aud ?? null,
1230
- iss: claims.iss ?? null,
1231
- email_verified: claims.email_verified ?? null,
1232
- sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
1233
- idp_url: idpUrl,
1234
- });
1235
- return idpErrorCode ?? false;
1236
- }
1237
-
1238
- let idpData: any;
1239
- try { idpData = JSON.parse(oauthResText); } catch { return false; }
1240
- const result = idpData?.data?.result || idpData?.data || idpData;
1241
-
1242
- if (!result?.access_token) {
1243
- console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
1244
- return false;
1245
-
1246
- }
1247
-
1248
- // Build IDP token data
1249
- //
1250
- // 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
1251
- // 2026-08-08 — see mail thread, not re-derived here):
1252
- // 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
1253
- // per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
1254
- // (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
1255
- // change needed or wanted: emitting a second, camelCase name for the
1256
- // same fact would be the dialect problem the estate law exists to
1257
- // prevent, not a fix.
1258
- // 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
1259
- // BAPert's first draft of this contract said "read both casings,
1260
- // defensively" — DotNetPert rejected that: a dual-casing client-side
1261
- // read gives one fact two wire names just as surely as a dual-casing
1262
- // IDP emit would, and it is the exact audience-dialect mistake
1263
- // (186443) made deliberately instead of by accident. This package
1264
- // previously read camel ONLY (card 170083, now corrected — it must
1265
- // NOT record "emit both casings" as the IDP-side fix), which is why
1266
- // this line silently fell through to the heuristic below on every
1267
- // real call. The fix is reading the one name the IDP actually sends,
1268
- // not tolerating both.
1269
- // 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
1270
- // not patched. DotNetPert traced ExternalAuthController's
1271
- // oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
1272
- // from the SAME isFullyAuthenticated variable, so they cannot
1273
- // disagree on this path — the divergence this fallback existed for
1274
- // is not present here. Worse, the heuristic would currently produce
1275
- // the RIGHT answer by coincidence, which hides the day it stops
1276
- // being derived from one variable.
1277
- // 4. requires_two_factor is non-nullable, so reaching the `true`
1278
- // default below means the response ISN'T an OAuthCallbackResponse at
1279
- // all (wrong endpoint / error body / failed parse) — logged as that,
1280
- // not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
1281
- // either way (rule 3).
1282
- // 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
1283
- // response — no hardcoding, no carrying a prior session value
1284
- // forward (that half of the monotonic-OR defect lived in
1285
- // refresh.ts / ensure-fresh-access-token.ts, fixed separately).
1286
- //
1287
- // PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
1288
- // every OAuth exchange as a blunt workaround for a since-disproven IDP
1289
- // bug, which itself caused every Google user to be sent to /mfa even on
1290
- // require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
1291
- // demo tenant). That workaround is removed along with the bug it worked
1292
- // around.
1293
- const requiresTwoFactor: boolean = (() => {
1294
- // SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
1295
- // a "defensive" secondary would give one fact two wire names — exactly
1296
- // the audience-dialect mistake (186443) being made deliberately. The
1297
- // estate law is explicit-snake-per-field-both-serializers; the IDP
1298
- // already complies (OAuthCallbackResponse.cs, both System.Text.Json
1299
- // and Newtonsoft). No IDP change, no camel read, no dual-casing.
1300
- const v = result.requires_two_factor ?? result.user?.requires_two_factor;
1301
- if (v === undefined) {
1302
- console.warn(
1303
- '[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
1304
- 'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
1305
- 'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
1306
- 'Defaulting requiresTwoFactor=true (fail closed).',
1307
- );
1308
- return true;
1309
- }
1310
- return !!v;
1311
- })();
1312
- const idpTokenData = {
1313
- idpAccessToken: result.access_token,
1314
- idpRefreshToken: result.refresh_token,
1315
- idpAccessTokenExpires: result.expires_in
1316
- ? Date.now() + result.expires_in * 1000
1317
- : Date.now() + 15 * 60 * 1000,
1318
- userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
1319
- email: result.user?.email || result.email || email,
1320
- name: result.user?.full_name || result.user?.name || result.name || name,
1321
- image: image,
1322
- roles: result.user?.roles || result.roles || [],
1323
- mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
1324
- // Cards 141688/141741 — RELAYED, NOT DERIVED.
1325
- //
1326
- // The IDP already decides this (ExternalAuthController:943 and :2024,
1327
- // WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
1328
- // confirmed method exists, null = fully authenticated. It was emitted
1329
- // here and dropped on this line — `idpTokenData` is built field-by-field,
1330
- // so anything not named is silently discarded. Both downstream consumers
1331
- // (the middleware gate and the verify-code page) then reasoned in its
1332
- // absence and sent zero-method users to a page they cannot act on.
1333
- //
1334
- // WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
1335
- // BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
1336
- // `result.nextStep` here would be `undefined` forever and fail silently —
1337
- // the same camel/snake break `requires_two_factor` already cost us once.
1338
- //
1339
- // Do NOT recompute this from a method query. The IDP owns the fact; it is
1340
- // already usable-aware (confirmed and not revoked, per DotNetPert), which
1341
- // is the distinction that decides correctness: a revoked-TOTP user has
1342
- // count>0 but usable=0, and deriving it locally would let through exactly
1343
- // the users we must challenge.
1344
- // No `?? null`: if the response has no `next_step` at all (older IDP
1345
- // build, or a path that does not set it), that is UNKNOWN, not "fully
1346
- // authenticated". Defaulting it to null here would manufacture a
1347
- // clean-bill-of-health the IDP never gave and fail open at the gate.
1348
- nextStep: result.next_step,
1349
- idpClientId: result.client_id ? String(result.client_id) : undefined,
1350
- merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
1351
- };
1352
-
1353
- // Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
1354
- const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
1355
- if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
1356
- // true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
1357
- return stored;
1358
- } catch (err) {
1359
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
1360
- return false;
1361
- }
1362
- }
1363
-
1364
-
1365
- /**
1366
- * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
1367
- * function never acquires tokens and never decides who the caller is.
1368
- *
1369
- * ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
1370
- *
1371
- * `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
1372
- * POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
1373
- * the second half is reusable, and the first half is actively dangerous to
1374
- * reuse:
1375
- *
1376
- * oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
1377
- * cryptographic verification of any kind (card 138635).
1378
- *
1379
- * Passkey sign-in already holds something strictly better — a grant issued by
1380
- * `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
1381
- * signature against a registered credential. Routing that through
1382
- * oauth-callback to obtain a session would DOWNGRADE a cryptographically
1383
- * verified assertion into an unverified one, make passkey sign-in weaker than
1384
- * its own ceremony, and inherit 138635 wholesale.
1385
- *
1386
- * That is not hypothetical: "do what the OAuth path does" is the obvious
1387
- * implementation and it is the wrong one. Splitting acquisition from storage is
1388
- * what makes the wrong one hard to write by accident.
1389
- *
1390
- * ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
1391
- *
1392
- * A Better Auth session must ALREADY EXIST for `sessionToken`. This function
1393
- * enriches; it does not create. Better Auth owns session creation and cookie
1394
- * signing, and nothing in this package should replicate either — the cookie is
1395
- * `{token}.{base64(hmac)}` with standard padding, signed with the runtime
1396
- * `authSecret` from Redis, and hand-rolling that produces a session which
1397
- * decodes but does not authenticate.
1398
- *
1399
- * @param sessionToken the Better Auth session token (from its Set-Cookie)
1400
- * @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
1401
- * verified route. Built field-by-field by the caller so an
1402
- * unnamed field cannot ride along silently.
1403
- * @param authProvider provenance label recorded on the canonical row. Written
1404
- * to the historical `oauthProvider` key, which predates
1405
- * non-OAuth callers; the NAME is legacy, the VALUE should
1406
- * state what actually authenticated ('passkey', 'google').
1407
- */
1408
- export async function storeIdpTokensInSession(
1409
- sessionToken: string,
1410
- idpTokenData: Record<string, unknown>,
1411
- authProvider: string,
1412
- ): Promise<boolean> {
1413
- try {
1414
- const config = await getIDPClientConfig();
1415
- const appSlug = config.clientSlug || getAppSlug();
1416
- const baKey = `ba:${appSlug}:${sessionToken}`;
1417
-
1418
- const baRaw = await getRedis().get(baKey).catch(() => null);
1419
- if (!baRaw) {
1420
- // No session to enrich. Do NOT create one as a fallback: a session
1421
- // conjured here would carry no Better Auth cookie, so it would be
1422
- // unreachable by the browser and invisible to every other consumer —
1423
- // failing as "silently signed out" rather than as an error.
1424
- console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', tokenLogId(sessionToken));
1425
- return false;
1426
- }
1427
-
1428
- const baData = JSON.parse(baRaw);
1429
- baData.idpTokens = idpTokenData;
1430
- await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
1431
-
1432
- // Canonical store — the key getSession() reads, used by the refresh handler
1433
- // and token lifecycle. A failure here leaves the BA session correct and the
1434
- // canonical row missing, so it is warned and not fatal: the user is signed
1435
- // in, and refresh degrades rather than the sign-in failing outright.
1436
- try {
1437
- const { getSessionPrefix } = await import('../lib/app-slug');
1438
- const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
1439
- await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
1440
- ...idpTokenData,
1441
- oauthProvider: authProvider,
1442
- }));
1443
- } catch (canonicalErr) {
1444
- console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
1445
- }
1446
-
1447
- return true;
1448
- } catch (err) {
1449
- console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
1450
- return false;
1451
- }
1452
- }
1453
-
1454
- /**
1455
- * The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
1456
- * store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
1457
- * Try again or use another method." The cause stays in the server log, never on the page.
1458
- */
1459
- export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
1460
-
1461
- /**
1462
- * PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
1463
- * verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
1464
- * purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
1465
- */
1466
- export async function storePayezIdpSession(sessionToken: string): Promise<boolean> {
1467
- const captured = idTokenScope.getStore()?.idp;
1468
- if (!captured?.tokens?.accessToken || !captured.claims) {
1469
- console.warn('[BETTER_AUTH] payez-idp callback: no verified IdP tokens captured for this request');
1470
- return false;
1471
- }
1472
- const { tokens, claims } = captured;
1473
- const stored = await storeIdpTokensInSession(
1474
- sessionToken,
1475
- {
1476
- idpAccessToken: tokens.accessToken,
1477
- idpRefreshToken: tokens.refreshToken,
1478
- idpAccessTokenExpires: tokens.accessTokenExpires ?? Date.now() + 15 * 60 * 1000,
1479
- userId: String(claims.userId ?? claims.sub),
1480
- email: claims.email,
1481
- name: claims.name,
1482
- roles: claims.roles,
1483
- mfaVerified: claims.mfaVerified,
1484
- nextStep: undefined,
1485
- },
1486
- 'payez-idp',
1487
- );
1488
- if (stored) console.log('[BETTER_AUTH] IdP tokens stored in session for', claims.email);
1489
- return stored;
1490
- }
1491
-
1492
- /**
1493
- * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
1494
- *
1495
- * Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
1496
- * log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
1497
- * the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
1498
- * line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
1499
- * (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
1500
- * wearing a different number. Add them together, never one alone.
1501
- */
1502
- export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
1503
-
1504
-
1505
- /**
1506
- * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
1507
- * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
1508
- * accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
1509
- */
1510
- export function expireSetCookies(setCookies: readonly string[]): string[] {
1511
- const out: string[] = [];
1512
- for (const sc of setCookies) {
1513
- const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
1514
- const eq = pair.indexOf('=');
1515
- const name = eq > 0 ? pair.slice(0, eq) : '';
1516
- if (!name) continue;
1517
- const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
1518
- out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
1519
- }
1520
- return out;
1521
- }
1522
-
1523
- /**
1524
- * PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
1525
- *
1526
- * Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
1527
- * just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
1528
- * user's email and failed every call - a red "sign in again" screen about an account the app could no longer
1529
- * authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
1530
- *
1531
- * So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
1532
- * `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
1533
- * expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
1534
- * branch above (328439: an absolute URL built from request.url is a dead tab in a container).
1535
- */
1536
- async function abortOAuthSignIn(
1537
- callbackResponse: Response,
1538
- sessionToken: string | null,
1539
- cookieHeader: string,
1540
- loginPath: string,
1541
- errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
1542
- ): Promise<Response> {
1543
- const headers = new Headers();
1544
- if (sessionToken) {
1545
- try {
1546
- const auth = await getBetterAuthInstance();
1547
- const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
1548
- for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
1549
- } catch (err) {
1550
- console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
1551
- }
1552
- try {
1553
- const config = await getIDPClientConfig();
1554
- await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
1555
- } catch (err) {
1556
- console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), err instanceof Error ? err.message : String(err));
1557
- }
1558
- }
1559
- for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
1560
- headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
1561
- console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
1562
- return new Response(null, { status: 302, headers });
1563
- }
1564
-
1565
- /**
1566
- * Create a production-ready GET handler for the auth catch-all route.
1567
- *
1568
- * Wraps better-auth's GET handler with:
1569
- * - OAuth state error recovery (redirects to login instead of error page)
1570
- * - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
1571
- * (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
1572
- *
1573
- * Usage in host app:
1574
- * ```ts
1575
- * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
1576
- * export const GET = createAuthGetHandler('/account-auth/login');
1577
- * export async function POST(req: Request) {
1578
- * const ba = await getBetterAuthHandler();
1579
- * return ba!.POST(req);
1580
- * }
1581
- * ```
1582
- */
1583
- export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
1584
- return async function GET(request: Request): Promise<Response> {
1585
- // Card 138635: establish the request-scoped carrier for the provider's raw
1586
- // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
1587
- // hook that sees the token) and the exchange below (which forwards it).
1588
- // Wrapping the whole handler is what makes "never persisted" achievable —
1589
- // the token exists only for the life of this request.
1590
- return idTokenScope.run({}, async () => {
1591
- const ba = await getBetterAuthHandler();
1592
- if (!ba) {
1593
- return new Response('Auth handler not configured', { status: 500 });
1594
- }
1595
-
1596
- const response = await ba.GET(request);
1597
-
1598
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
1599
- if (response.status === 302) {
1600
- const location = response.headers.get('location') || '';
1601
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
1602
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1603
- // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
1604
- // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
1605
- // runtime and again on node 24:
1606
- // Response.redirect('/account-auth/login', 302)
1607
- // -> TypeError: Failed to parse URL from /account-auth/login
1608
- // That requirement is the whole cause of the bug this replaces. The old line
1609
- // was:
1610
- // return Response.redirect(new URL(loginPath, request.url), 302);
1611
- // `request.url` inside a containerised Next standalone server carries the
1612
- // LISTEN address, not the address the browser used, so a container bound to
1613
- // 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
1614
- // that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
1615
- // by QAPert). Reload cannot recover it, because the address is invalid rather
1616
- // than down. It fired on the ORDINARY path: the oauth_state cookie is
1617
- // Max-Age=600, so anyone spending >10 minutes at Google's account chooser
1618
- // landed on a dead tab.
1619
- //
1620
- // A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
1621
- // resolves it against the origin it is ACTUALLY on, so no origin has to be
1622
- // guessed, configured, or derived from a header. This is the same shape the
1623
- // app's own middleware already emits successfully (307 + relative), which is
1624
- // why page redirects survived while this one did not.
1625
- //
1626
- // WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
1627
- // (a) rawBaseURL is NOT in scope here — it is a local inside
1628
- // createBetterAuthInstance (~:397), a different function ~676 lines up.
1629
- // Reaching it means recomputing the env precedence or exporting a
1630
- // helper, i.e. more moving parts for a strictly worse result.
1631
- // (b) It would reintroduce a guessed origin and make this path depend on
1632
- // BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
1633
- // (c) Anything Host-derived would open host-header injection on a redirect
1634
- // that is currently host-INVARIANT. Relative keeps that property.
1635
- //
1636
- // Works whether loginPath is relative (package default '/account-auth/login',
1637
- // and the graft passes '/sign-in') or absolute — an absolute Location is also
1638
- // legal, so a consumer passing a full URL still behaves.
1639
- return new Response(null, { status: 302, headers: { location: loginPath } });
1640
- }
1641
- }
1642
-
1643
- // After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
1644
- // IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
1645
- // kept only when it holds IdP tokens.
1646
- const url = new URL(request.url);
1647
- if (url.pathname.includes('/callback/') && response.status === 302) {
1648
- const setCookies = response.headers.getSetCookie?.() || [];
1649
- const cookieHeader = setCookies
1650
- .map((c: string) => c.split(';')[0])
1651
- .join('; ');
1652
- let sessionToken: string | null = null;
1653
- // true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
1654
- // (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
1655
- let exchange: boolean | string = false;
1656
- try {
1657
- const auth = await getBetterAuthInstance();
1658
- if (auth?.api?.getSession) {
1659
- const headers = new Headers();
1660
- headers.set('cookie', cookieHeader);
1661
-
1662
- const session = await auth.api.getSession({ headers });
1663
- if (session?.session?.token) {
1664
- sessionToken = session.session.token;
1665
- console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
1666
- // PAY-2305: the payez-idp callback already holds an IdP-ISSUED bearer (verified in getUserInfo). It must NOT go
1667
- // through oauth-callback, the asserted-email path (card 138635), and storing it is what the passkey path does.
1668
- exchange = url.pathname.endsWith('/callback/payez-idp')
1669
- ? await storePayezIdpSession(session.session.token)
1670
- : await exchangeOAuthForIdpTokens(session.session.token);
1671
- } else {
1672
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
1673
- }
1674
- }
1675
- } catch (err: any) {
1676
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
1677
- }
1678
- if (exchange !== true) {
1679
- // A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
1680
- const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
1681
- return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
1682
- }
1683
- }
1684
-
1685
- return response;
1686
- });
1687
- };
1688
- }
1
+ /**
2
+ * Better Auth Configuration
3
+ *
4
+ * Primary auth configuration. Replaces the former NextAuth auth-options.ts.
5
+ *
6
+ * Architecture: No database adapter — Better Auth runs in stateless mode
7
+ * with JWE cookie cache. User management stays on IDP, sessions on Redis.
8
+ *
9
+ * @see BETTER-AUTH-MIGRATION-SPEC.md
10
+ */
11
+
12
+ import 'server-only';
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { createHash } from 'crypto';
15
+ import { decodeJwt } from 'jose'; // better-auth's own JWT dependency
16
+ import { betterAuth } from 'better-auth';
17
+ import { nextCookies } from 'better-auth/next-js';
18
+ import { toNextJsHandler } from 'better-auth/next-js';
19
+ import { wrapBetterAuthHandler } from './session-response-sanitizer';
20
+ import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
21
+ import { resolveClientKey } from '../lib/client-key';
22
+ import { genericOAuth } from 'better-auth/plugins/generic-oauth';
23
+ import {
24
+ createIdpOAuthHooks,
25
+ resolveIdpRedirectUri,
26
+ type IdpTokenGrant,
27
+ type IdpVerifiedClaims,
28
+ } from './idp-oauth';
29
+ import type { IDPClientConfig } from '../lib/idp-client-config';
30
+ import { getIDPClientConfig } from '../lib/idp-client-config';
31
+ import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
32
+ import { getRedis } from '../lib/redis';
33
+ import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
34
+ import { SESSION_TTL } from '../lib/session-store';
35
+ import { tokenLogId } from '../lib/token-log-id';
36
+
37
+ /**
38
+ * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
39
+ * to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
40
+ * = 48 bits; collision-tellable across a deployment, useless as a credential.
41
+ */
42
+ export function secretFingerprint(value: string): string {
43
+ return createHash('sha256').update(value).digest('hex').slice(0, 12);
44
+ }
45
+
46
+ /**
47
+ * 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
48
+ * than Better Auth, which must survive a Better Auth session write.
49
+ *
50
+ * Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
51
+ * list is a named constant rather than a hardcoded check because the shape of
52
+ * the bug is "a second author appeared and the adapter did not know" — the next
53
+ * enrichment key should be added here, not rediscovered as a strip.
54
+ */
55
+ const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
56
+
57
+ /**
58
+ * True for a JSON object we can safely merge into — not null, not an array.
59
+ * `typeof null === 'object'` and arrays are objects, and merging into either
60
+ * would corrupt the record rather than preserve it.
61
+ */
62
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ /**
67
+ * Better Auth social provider config shape.
68
+ */
69
+ export interface BetterAuthSocialProvider {
70
+ clientId: string;
71
+ clientSecret: string;
72
+ scope?: string[];
73
+ prompt?: string;
74
+ accessType?: 'offline' | 'online';
75
+ hd?: string;
76
+ /**
77
+ * 260419: better-auth's ONLY guard against OAuth auto-provisioning
78
+ * (oauth2/link-account.mjs handleOAuthUserInfo: `if (disableSignUp) return
79
+ * {error:"signup disabled"}`). Unset means ANY unknown Google identity gets a
80
+ * local account created at callback. We wire it from the tenant's own policy.
81
+ */
82
+ disableSignUp?: boolean;
83
+ }
84
+
85
+ /**
86
+ * Build Better Auth social providers from IDP config.
87
+ */
88
+ /**
89
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
90
+ *
91
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
92
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
93
+ * for the IDP to require a provider-signed id_token and verify it itself. This
94
+ * is the client half: capture the token Better Auth already receives, and
95
+ * forward it. Additive — the IDP ignores it until its gate flips.
96
+ *
97
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
98
+ * an id_token is credential material. Writing it to the session record would put
99
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
100
+ * and it would outlive the request that needed it. ALS scopes it to the single
101
+ * request, and it is gone when the request ends — nothing to expire, nothing to
102
+ * scrub, nothing to leak.
103
+ *
104
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
105
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
106
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
107
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
108
+ * function. Outside that scope the store is undefined and the body simply omits
109
+ * id_token, which is the correct additive behaviour.
110
+ */
111
+ /**
112
+ * ON globalThis FOR THE SAME REASON THE BETTER AUTH INSTANCE IS (181814, see STATE_KEY
113
+ * below) — and it was NOT, which is the whole defect.
114
+ *
115
+ * This module is loaded more than once per process: instrumentation and the auth route
116
+ * are different chunks with their own module registries. `state` was moved to globalThis
117
+ * so both copies share the Better Auth INSTANCE. This AsyncLocalStorage was left in
118
+ * module scope, so each copy got its own.
119
+ *
120
+ * The result, measured on UMI 2026-08-26 against idp.payez.net:
121
+ *
122
+ * copy A builds the instance -> the getUserInfo closure captures A's idTokenScope
123
+ * copy B serves the callback -> createAuthGetHandler runs .run({}) on B's
124
+ * getUserInfo fires, reads A -> getStore() is undefined, `if (scope)` skips
125
+ * the exchange reads B -> empty, so id_token is omitted from the body
126
+ *
127
+ * Sign-in SUCCEEDS at every visible step. The user gets a session, an avatar, an email.
128
+ * The IDP then answers 401 UNVERIFIED_ASSERTION with reason="id_token missing"
129
+ * (confirmed in its own logs, correlationId 0HNO2755RESR1) — so the client half of 138635
130
+ * has been shipping as a no-op wherever the module double-loads, and the security fix it
131
+ * exists to deliver never arrived. A shared instance plus a per-copy scope is the worst
132
+ * combination: everything looks wired, and the one field that proves identity is dropped.
133
+ *
134
+ * globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
135
+ * carrier needs to be reachable from every copy that might run inside the request.
136
+ */
137
+ /**
138
+ * THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
139
+ * THINGS. Renaming it would give a copy on the old name and a copy on the new
140
+ * name two DIFFERENT stores in the same process — which is precisely the
141
+ * double-load failure documented above, reintroduced by a cosmetic rename. The
142
+ * name is a wire contract between copies, not a description.
143
+ *
144
+ * accessToken is here for providers that do NOT issue an id_token. GitHub is
145
+ * not an OIDC provider: it has no id_token, no JWKS and no discovery document,
146
+ * so `id_token` can never be the field that proves a GitHub identity. The IDP
147
+ * re-derives that identity by calling GitHub itself with this access token
148
+ * (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
149
+ * verifies for itself and never trusts an asserted email.
150
+ */
151
+ const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
152
+ type ProviderTokenScope = {
153
+ idToken?: string;
154
+ accessToken?: string;
155
+ /** PAY-2305: what the payez-idp getToken / getUserInfo hooks captured for THIS request (never persisted here). */
156
+ idp?: { tokens?: IdpTokenGrant; claims?: IdpVerifiedClaims };
157
+ };
158
+ const gScope = globalThis as unknown as Record<
159
+ string,
160
+ AsyncLocalStorage<ProviderTokenScope> | undefined
161
+ >;
162
+ gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
163
+ const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
164
+
165
+ /**
166
+ * EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
167
+ * provider credentials in the app.
168
+ *
169
+ * ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
170
+ * Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
171
+ * is uniform with the ratified desktop leg. The IDP holds the provider secret
172
+ * and performs the provider exchange server-side.
173
+ *
174
+ * ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
175
+ * QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
176
+ * clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
177
+ * on whether we need a secret. This builds the generic/custom shape
178
+ * deliberately; reaching for a preset because it looks tidier would silently
179
+ * kill keyless-on-web and nobody would connect the two changes.
180
+ *
181
+ * ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
182
+ * Returns null unless the IDP's OAuth endpoints are explicitly configured. That
183
+ * makes this ADDITIVE: the existing socialProviders path keeps working until an
184
+ * operator turns this on. It is NOT a runtime failover - nothing silently
185
+ * chooses between them, because a silent chooser is how a migration ends up
186
+ * half-applied and undetectable.
187
+ *
188
+ * ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
189
+ * the app stops talking to Google, so it can no longer capture the raw
190
+ * id_token that oauth-callback verifies today, and the per-provider settings
191
+ * below stop being ours to set - profile scope, prompt=select_account,
192
+ * accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
193
+ * exchange must verify signature/iss/aud/exp itself and preserve those four, or
194
+ * they are lost silently.
195
+ */
196
+ export interface IdpOAuthProviderConfig {
197
+ providerId: string;
198
+ discoveryUrl: string;
199
+ clientId: string;
200
+ pkce: true;
201
+ scopes: string[];
202
+ /** PAY-2305: the REGISTERED {site_url}/api/auth/callback, sent identically at authorize and token time. */
203
+ redirectURI: string;
204
+ getToken: ReturnType<typeof createIdpOAuthHooks>['getToken'];
205
+ getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
206
+ }
207
+
208
+ export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null {
209
+ const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
210
+ const clientId = process.env.CLIENT_ID;
211
+ if (!discoveryUrl || !clientId) return null;
212
+
213
+ const redirectURI = resolveIdpRedirectUri(
214
+ appBaseUrl || process.env.BETTER_AUTH_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || '3000'}`,
215
+ );
216
+ const hooks = createIdpOAuthHooks({
217
+ discoveryUrl,
218
+ clientId,
219
+ redirectURI,
220
+ issuer: process.env.IDP_OAUTH_ISSUER || undefined,
221
+ trustedOrigins: process.env.IDP_OAUTH_TRUSTED_ORIGINS
222
+ ? process.env.IDP_OAUTH_TRUSTED_ORIGINS.split(',').map((x) => x.trim()).filter(Boolean)
223
+ : undefined,
224
+ onTokens: (tokens) => {
225
+ const store = idTokenScope.getStore();
226
+ if (store) (store.idp ??= {}).tokens = tokens;
227
+ },
228
+ onClaims: (claims) => {
229
+ const store = idTokenScope.getStore();
230
+ if (store) (store.idp ??= {}).claims = claims;
231
+ },
232
+ });
233
+
234
+ return {
235
+ providerId: 'payez-idp',
236
+ discoveryUrl,
237
+ clientId,
238
+ redirectURI,
239
+ getToken: hooks.getToken,
240
+ getUserInfo: hooks.getUserInfo,
241
+ // NO clientSecret. The whole point: this app is not a credential holder.
242
+ pkce: true,
243
+ scopes: ['openid', 'email', 'profile'],
244
+
245
+ /**
246
+ * ⚠ DO NOT ADD `authentication: 'basic'` HERE. IT SILENTLY BREAKS KEYLESS.
247
+ *
248
+ * Found by QAPert-NightHawk (21348) — the second config trap in this plugin
249
+ * after the preset one, and the same shape: a field that looks like a
250
+ * harmless transport detail and quietly changes whether we are a public
251
+ * client at all.
252
+ *
253
+ * @better-auth/core validate-authorization-code.mjs:
254
+ * if (authentication === "basic") {
255
+ * headers.authorization = `Basic ${base64(`${clientId}:${clientSecret ?? ""}`)}`;
256
+ * } else {
257
+ * body.set("client_id", clientId);
258
+ * if (options.clientSecret) body.set("client_secret", options.clientSecret);
259
+ * }
260
+ *
261
+ * Note what the 'basic' branch does with NO secret: it sends an EMPTY
262
+ * password, `Basic base64("<clientId>:")`. That is not "no authentication",
263
+ * it is WRONG authentication — we would be presenting ourselves as a
264
+ * confidential client with a blank credential. The default branch is the
265
+ * one that produces a genuine public-client PKCE request.
266
+ *
267
+ * Leaving this field UNSET is load-bearing, which is exactly why it needs a
268
+ * comment: nothing about an absent field advertises that its absence is a
269
+ * decision.
270
+ */
271
+ };
272
+ }
273
+
274
+ export function buildBetterAuthProviders(
275
+ config: IDPClientConfig
276
+ ): Record<string, BetterAuthSocialProvider> {
277
+ const providers: Record<string, BetterAuthSocialProvider> = {};
278
+
279
+ for (const oauth of config.oauthProviders || []) {
280
+ if (!oauth.enabled) continue;
281
+ const name = oauth.provider.toLowerCase();
282
+ const additionalParams = oauth.additionalParams ?? {};
283
+ const rawPrompt = additionalParams.prompt;
284
+ const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
285
+ const rawHostedDomain = additionalParams.hd;
286
+
287
+ // Ensure profile scope is present for Google so avatar image is returned
288
+ const scopes = oauth.scopes?.split(' ') || [];
289
+ if (name === 'google' && !scopes.includes('profile')) {
290
+ scopes.push('profile');
291
+ }
292
+
293
+ /**
294
+ * 224721: better-auth's google provider UNCONDITIONALLY seeds its default
295
+ * scopes ['email','profile','openid'] and then pushes our `scope` verbatim
296
+ * (@better-auth/core social-providers/google.mjs createAuthorizationURL) —
297
+ * the row's "openid email profile" reached Google as
298
+ * "email profile openid openid email profile". Semantically harmless
299
+ * (Google dedupes) but sloppy on a URL a demo audience can read. Hand over
300
+ * only what the defaults do not already cover; undefined when nothing
301
+ * remains, so the provider emits exactly its default set.
302
+ */
303
+ const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
304
+ /**
305
+ * Same defect, same fix, different provider: github's createAuthorizationURL
306
+ * also seeds ['read:user','user:email'] unconditionally and then pushes ours
307
+ * on top (@better-auth/core social-providers/github.ts), so the stored row
308
+ * "read:user user:email" reached GitHub as
309
+ * "read:user user:email read:user user:email". Measured on :4799, not
310
+ * assumed. GitHub dedupes, so this is legibility rather than function — but
311
+ * a scope list is the first thing a security reviewer reads off that URL.
312
+ */
313
+ const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
314
+ const wireScopes =
315
+ name === 'google'
316
+ ? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
317
+ : name === 'github'
318
+ ? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
319
+ : scopes;
320
+
321
+ providers[name] = {
322
+ clientId: oauth.clientId,
323
+ clientSecret: oauth.clientSecret,
324
+ scope: wireScopes.length > 0 ? wireScopes : undefined,
325
+ /**
326
+ * PAY-2005 — DISABLE-SIGN-UP IS REMOVED, DELIBERATELY. DO NOT RE-ADD IT.
327
+ *
328
+ * 260419 wired this from `config.authSettings?.allowPublicRegistration` to
329
+ * close a client-side self-serve provisioning door. It cannot do that job
330
+ * correctly, and it BREAKS SIGN-IN:
331
+ *
332
+ * - This app runs better-auth in STATELESS mode (no DB adapter — see the
333
+ * file header). better-auth therefore has NO user store, so at the OAuth
334
+ * callback it cannot tell an EXISTING user from a NEW one. Its
335
+ * handleOAuthUserInfo consults `disableSignUp` for BOTH, so
336
+ * `disableSignUp: true` refuses a returning user exactly like a
337
+ * stranger. Measured live 2026-09-30 (PAY-2005): portal.keelauth.com
338
+ * Google sign-in bounced to /sign-in with Better Auth `signup_disabled`
339
+ * once client 12's allow_public_registration reported false.
340
+ *
341
+ * - The REAL registration gate already exists and is correct, at the IDP:
342
+ * PayEz.External.Id.Api ExternalAuthController.oauth-callback looks the
343
+ * user up by verified email (:799); an existing user logs in UNTOUCHED
344
+ * (:811); only the isNewUser branch (:853) calls INewUserRegistrar,
345
+ * which refuses fail-closed when the client forbids public registration
346
+ * (NewUserRegistrar.cs:65, `SecurityFlags.AllowPublicRegistration != true`).
347
+ * Its own comment states the contract: "existing federated users ...
348
+ * log in untouched, so gating here never blocks login."
349
+ *
350
+ * So this flag was a DUPLICATE gate that could only ever misfire — it cannot
351
+ * distinguish new from existing, and the IDP already refuses new identities.
352
+ * Removing it restores sign-in for existing users WITHOUT reopening
353
+ * self-serve: a NEW identity that completes the better-auth leg still gets
354
+ * REFUSED at the IDP exchange and never receives IDP tokens, which is the
355
+ * portal's actual authorization gate.
356
+ *
357
+ * WHERE "no self-serve sign-up" IS ENFORCED NOW: the IDP's NewUserRegistrar
358
+ * (above) as the backstop, and each app's own front-door sign-up gate
359
+ * (e.g. the portal's signup-gate.ts / SignUpCard) for the UI affordance.
360
+ * Both are correct and neither is this flag.
361
+ */
362
+ // Google is overly eager to reuse the last account unless we
363
+ // explicitly ask for account selection on each social login.
364
+ prompt: typeof rawPrompt === 'string'
365
+ ? rawPrompt
366
+ : name === 'google'
367
+ ? 'select_account'
368
+ : undefined,
369
+ accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
370
+ hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
371
+ };
372
+
373
+ /**
374
+ * CAPTURE THE RAW id_token — card 138635, client half.
375
+ *
376
+ * getUserInfo is the ONLY provider hook that receives the token set.
377
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
378
+ * signed JWT cannot be reconstructed from them. We need the raw token
379
+ * because the IDP must verify the signature ITSELF — a client-decoded
380
+ * claim is just another assertable field, which is the defect restated.
381
+ *
382
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
383
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
384
+ * returns our result verbatim, so everything the default did is now ours to
385
+ * do — including the `hd` hosted-domain restriction.
386
+ *
387
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
388
+ * above, so a client can restrict sign-in to a Google Workspace domain.
389
+ * Silently dropping it would let ANY Google account into a client that had
390
+ * restricted itself — a security regression introduced by a security fix.
391
+ * (Measured on 93: zero clients set it today. That is a reason to be
392
+ * careful, not a reason to skip it — it is configurable and unused, which
393
+ * is exactly the state in which a regression goes unnoticed.)
394
+ *
395
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
396
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
397
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
398
+ * default would return true unconditionally, so omitting it is equivalent
399
+ * by the library's own definition rather than by our judgement.
400
+ *
401
+ * A client that DOES set hd keeps the stock provider, keeps its domain
402
+ * restriction, and simply does not contribute an id_token yet. Losing a
403
+ * capability is an acceptable cost; silently losing a security control is
404
+ * not. (better-auth does not re-export the predicate through its public
405
+ * types, and reimplementing a security check is how two copies drift — so
406
+ * the honest move is to not need it.)
407
+ */
408
+ if (name === 'google' && !providers[name].hd) {
409
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: { idToken?: string }) => {
410
+ if (!token?.idToken) return null;
411
+
412
+ // The only new behaviour: hand the raw token to this request's scope.
413
+ const scope = idTokenScope.getStore();
414
+ if (scope) {
415
+ scope.idToken = token.idToken;
416
+ } else {
417
+ // NOT a silent skip. A missing scope here means 138635 degrades to a no-op:
418
+ // the login still succeeds, and the IDP rejects the exchange with
419
+ // reason="id_token missing" a second later, with nothing connecting the two.
420
+ // That cost a day on UMI. If this fires, the OAuth callback is running outside
421
+ // createAuthGetHandler — either the route mounts the bare
422
+ // `routes/auth/nextauth` re-export instead of createAuthGetHandler, or this
423
+ // module has double-loaded and the scope is not shared (see 181814 above).
424
+ console.warn(
425
+ '[BETTER_AUTH] id_token captured but NO REQUEST SCOPE to put it in — it will ' +
426
+ 'NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
427
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
428
+ );
429
+ }
430
+
431
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
432
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
433
+ // KEEP, never what we ACCEPT.
434
+ const claims = decodeJwt(token.idToken) as Record<string, any>;
435
+ return {
436
+ user: {
437
+ id: claims.sub,
438
+ name: claims.name,
439
+ email: claims.email,
440
+ image: claims.picture,
441
+ emailVerified: claims.email_verified,
442
+ },
443
+ data: claims,
444
+ };
445
+ };
446
+ }
447
+
448
+ /**
449
+ * GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
450
+ *
451
+ * GitHub is not an OIDC provider — it issues no id_token — so the google
452
+ * branch above can never fire for it and the exchange would post an
453
+ * assertion with nothing that proves identity. That is exactly the
454
+ * 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
455
+ * `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
456
+ * the IDP rejected it instantly, without ever calling GitHub.
457
+ *
458
+ * getUserInfo is the ONLY provider hook that receives the token set (see the
459
+ * google note above), and @better-auth/core's github provider calls
460
+ * `options.getUserInfo(token)` BEFORE its own body — so installing this
461
+ * REPLACES the default rather than wrapping it, and the default's work has
462
+ * to be reproduced here or the sign-in loses its profile entirely.
463
+ *
464
+ * Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
465
+ * the version the container actually runs (better-auth 1.5.6 — the graft's
466
+ * node_modules says 1.6.29 and is NOT what executes). Kept deliberately
467
+ * dumb: it must not "improve" on upstream, because every divergence here is
468
+ * a behaviour change disguised as a token capture.
469
+ *
470
+ * This is NOT the identity check. The IDP re-derives the GitHub identity
471
+ * itself from this access token and enforces primary && verified server-side
472
+ * (GitHubIdentityVerifier); what we return here only shapes the local
473
+ * better-auth user record.
474
+ */
475
+ if (name === 'github') {
476
+ (providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
477
+ accessToken?: string;
478
+ }) => {
479
+ const store = idTokenScope.getStore();
480
+ if (store) {
481
+ store.accessToken = token?.accessToken;
482
+ } else {
483
+ console.warn(
484
+ '[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
485
+ 'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
486
+ 'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
487
+ );
488
+ }
489
+
490
+ const ghHeaders = {
491
+ 'User-Agent': 'better-auth',
492
+ Authorization: `Bearer ${token?.accessToken}`,
493
+ };
494
+
495
+ const profileRes = await fetch('https://api.github.com/user', {
496
+ headers: ghHeaders,
497
+ });
498
+ // Upstream returns null on profile error, which fails the sign-in
499
+ // closed. Preserved rather than softened.
500
+ if (!profileRes.ok) {
501
+ console.warn(
502
+ `[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
503
+ );
504
+ return null;
505
+ }
506
+ const profile = (await profileRes.json()) as Record<string, any>;
507
+
508
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
509
+ headers: ghHeaders,
510
+ });
511
+ const emails = emailsRes.ok
512
+ ? ((await emailsRes.json()) as Array<{
513
+ email: string;
514
+ primary: boolean;
515
+ verified: boolean;
516
+ }>)
517
+ : undefined;
518
+
519
+ // A GitHub account with a private email returns profile.email = null;
520
+ // upstream falls back to the primary address here.
521
+ if (!profile.email && emails) {
522
+ profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
523
+ }
524
+ const emailVerified =
525
+ emails?.find((e) => e.email === profile.email)?.verified ?? false;
526
+
527
+ return {
528
+ user: {
529
+ id: profile.id,
530
+ name: profile.name || profile.login || '',
531
+ email: profile.email,
532
+ image: profile.avatar_url,
533
+ emailVerified,
534
+ },
535
+ data: profile,
536
+ };
537
+ };
538
+ }
539
+ }
540
+
541
+ return providers;
542
+ }
543
+
544
+ /**
545
+ * Optional configuration for `createBetterAuthInstance`.
546
+ *
547
+ * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
548
+ * The host app supplies its own `sendMagicLink` callback — typically a
549
+ * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
550
+ * Omit the `magicLink` key entirely to skip the plugin; the consuming
551
+ * app will not have a magic-link flow.
552
+ */
553
+ export interface CreateBetterAuthInstanceOptions {
554
+ magicLink?: MagicLinkOptions;
555
+ /**
556
+ * Extra Better Auth plugins supplied by the consuming app.
557
+ *
558
+ * WHY THIS EXISTS — session minting is not delegable any other way. BA's
559
+ * `setSessionCookie` requires a `GenericEndpointContext`, which only BA's own
560
+ * endpoint machinery constructs, so an app that must mint a session for an
561
+ * identity established OUTSIDE BA's built-in providers (passkey sign-in being
562
+ * the first) cannot do it from a route handler. It needs a real BA endpoint,
563
+ * and endpoints only arrive via plugins.
564
+ *
565
+ * Deliberately GENERIC. The package gains "an app may supply plugins" rather
566
+ * than any knowledge of passkeys — the alternative was teaching this file
567
+ * about a specific auth method, which is how a package accumulates a consumer's
568
+ * feature list.
569
+ *
570
+ * The alternative to this option is an app hand-rolling the session cookie
571
+ * (`{token}.{base64(hmac)}`, standard padding, runtime `authSecret` from
572
+ * Redis). That fails as a session which DECODES BUT DOES NOT AUTHENTICATE —
573
+ * so this option exists specifically to make that shortcut unnecessary.
574
+ */
575
+ plugins?: Parameters<typeof betterAuth>[0]['plugins'];
576
+ }
577
+
578
+ /**
579
+ * Create Better Auth instance from IDP config.
580
+ *
581
+ * No database — runs in stateless mode with JWE cookie cache.
582
+ * Call after getIDPClientConfig() resolves.
583
+ */
584
+ export function createBetterAuthInstance(
585
+ idpConfig: IDPClientConfig,
586
+ opts: CreateBetterAuthInstanceOptions = {}
587
+ ) {
588
+ assertAppSlugMatchesIdp(idpConfig.clientSlug);
589
+ const appSlug = idpConfig.clientSlug || getAppSlug();
590
+
591
+ // 185121 line 1: name the secret this instance PINNED, as a hash, never the
592
+ // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
593
+ // caused by a divergent secret is only diagnosable if every instance
594
+ // creation logged WHICH secret it bound. Both init paths (this module's
595
+ // getBetterAuthInstance and server/auth.ts getAuthInstance) funnel through
596
+ // here, so this one line covers both; getAuthInstance adds its own line
597
+ // naming the path, because it previously created an instance INVISIBLY.
598
+ console.log(
599
+ `[BETTER_AUTH] createBetterAuthInstance: slug=${appSlug} authSecret ${idpConfig.authSecret ? `sha256:${secretFingerprint(idpConfig.authSecret as string)}` : 'ABSENT'}`
600
+ );
601
+
602
+ // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
603
+ // Must include /api/auth since that's where the catch-all route is mounted
604
+ const rawBaseURL = process.env.BETTER_AUTH_URL
605
+ || idpConfig.baseClientUrl
606
+ || `http://localhost:${process.env.PORT || '3000'}`;
607
+ const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
608
+
609
+ const idpOAuth = buildIdpOAuthProviderConfig(rawBaseURL);
610
+
611
+ return betterAuth({
612
+ baseURL,
613
+ secret: idpConfig.authSecret as string,
614
+
615
+ socialProviders: buildBetterAuthProviders(idpConfig),
616
+
617
+ // Trust the app's own origin + any configured base URL
618
+ trustedOrigins: [
619
+ rawBaseURL,
620
+ baseURL,
621
+ ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
622
+ // MEASURED 2026-08-26: this list ended with the literals localhost:3000, :3400
623
+ // and :3600. payez_idp_admin_web runs on :3200 - correctly configured, with
624
+ // BETTER_AUTH_URL=http://localhost:3200 - and every GET /api/auth/get-session
625
+ // returned 400, because the port it actually runs on was not one of the three
626
+ // someone had typed in. A config-shaped constant is not configuration.
627
+ //
628
+ // The app's own origin is already covered by rawBaseURL/baseURL above; the dev
629
+ // origin is DERIVED from PORT, and anything genuinely external comes from
630
+ // BETTER_AUTH_TRUSTED_ORIGINS (comma-separated).
631
+ `http://localhost:${process.env.PORT || '3000'}`,
632
+ ...(process.env.BETTER_AUTH_TRUSTED_ORIGINS
633
+ ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
634
+ : []),
635
+ ],
636
+
637
+ // Redis-backed session storage via secondaryStorage
638
+ secondaryStorage: {
639
+ /**
640
+ * 170033 (QAPert 22654, load-bearing precondition for 183483): DO NOT
641
+ * swallow a Redis failure into `null` here.
642
+ *
643
+ * `null` from `get` is Better Auth's OWN signal for "no session at this
644
+ * key" (see node_modules/better-auth db/internal-adapter.mjs
645
+ * `findSession`: `if (!sessionStringified ...) return null`). Catching
646
+ * a Redis error and returning `null` therefore told better-auth "this
647
+ * session does not exist" when the true state was "the store could not
648
+ * be asked" — indistinguishable from a genuine sign-out. With
649
+ * `cookieCache` disabled (session.cookieCache.enabled above), EVERY
650
+ * `getSession()` call hits this path for real, so a Redis blip
651
+ * manufactured a `200 { session: null }` for every live session at
652
+ * once — the exact channel 183483's client-side guard (verify-code
653
+ * page's session-liveness check) was built to survive, and the one it
654
+ * could not survive while this swallow stood: a fabricated "no
655
+ * session" 200 is not distinguishable from a real one by any client.
656
+ *
657
+ * Letting the error propagate is safe: better-auth's own router
658
+ * (better-call/dist/router.mjs) wraps every endpoint handler in a
659
+ * top-level try/catch and converts an uncaught throw to a genuine
660
+ * `500`, not a crash. A `500` IS the "inconclusive" signal 183483's
661
+ * corrected client guard already knows how to hold a session through —
662
+ * so a Redis outage now surfaces as "couldn't check" instead of
663
+ * silently lying "confirmed logged out".
664
+ */
665
+ get: async (key: string) => {
666
+ const value = await getRedis().get(`ba:${appSlug}:${key}`);
667
+ // 185121 line 2: the 183047 discriminator. A null here means
668
+ // RECORD-MISS — the store was reachable and the key is absent. If the
669
+ // record IS present but BA still rejects the session, this line does
670
+ // NOT fire and the failure is upstream of storage (HMAC/secret gate).
671
+ // BA-primary logs nothing on failure, so without this line the two
672
+ // surviving forks are indistinguishable in the artifact. Key is
673
+ // fingerprinted: it embeds the session token.
674
+ if (value === null) {
675
+ console.warn(
676
+ `[BETTER_AUTH] secondaryStorage.get MISS ba:${appSlug}:sha256:${secretFingerprint(key)} — record absent, store reachable`
677
+ );
678
+ }
679
+ return value;
680
+ },
681
+ /**
682
+ * 170033 fix 1 — DO NOT SWALLOW A FAILED WRITE. A SESSION THAT WAS NOT
683
+ * PERSISTED IS NOT A SESSION.
684
+ *
685
+ * This previously caught every Redis error and returned normally, so
686
+ * better-auth was told the session had been stored when nothing had been
687
+ * written. The user got a successful login, a session cookie, and a
688
+ * rendered signed-in shell on top of a store that holds no session — and
689
+ * the very next `getSession()` reads `null` and signs them straight back
690
+ * out. Nothing logged, nothing thrown, no banner: a login that "worked"
691
+ * and a sign-out nobody can explain.
692
+ *
693
+ * Letting it propagate is the same contract `get` above already relies on:
694
+ * better-auth's router (better-call/dist/router.mjs) wraps every endpoint
695
+ * handler and converts an uncaught throw into a genuine 500. A 500 on the
696
+ * login write is honest — "we could not store your session" — and it is
697
+ * distinguishable by any client from a successful login, which a swallowed
698
+ * failure is not.
699
+ *
700
+ * THE COMMENT THIS REPLACES SAID "Redis unavailable — cookie cache still
701
+ * works". It does not: `session.cookieCache.enabled` is FALSE (see below).
702
+ * The comment described a safety net that had been removed, which is why
703
+ * this swallow looked survivable to every reader who trusted it (170033
704
+ * fix 3).
705
+ */
706
+ set: async (key: string, value: string, ttl?: number) => {
707
+ const redis = getRedis();
708
+ const redisKey = `ba:${appSlug}:${key}`;
709
+
710
+ /**
711
+ * 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
712
+ * (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
713
+ * evidence under BAPert's R920(a). Reproduced here before fixing.)
714
+ *
715
+ * Better Auth owns `{session, user}` and serialises exactly that. But
716
+ * `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
717
+ * `idpTokens` via its own read-modify-write. This setter did a blind
718
+ * `setex` of Better Auth's payload, so every write it makes — including
719
+ * each routine `updateAge` session rewrite, not merely an expiry —
720
+ * STRIPPED `idpTokens` back off the record.
721
+ *
722
+ * The consumer then falls back to an identity husk: `mfaVerified:false`,
723
+ * `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
724
+ * per BAPert it is a public-test blocker on its own terms, because a
725
+ * tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
726
+ * AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
727
+ * failure regardless of which direction it fails in.
728
+ *
729
+ * WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
730
+ * does a read-modify-write; it was correct. The bug is that ONE of the
731
+ * two authors of this key wrote as though it were the only one. Fixing
732
+ * the adapter fixes it for every future enrichment key, whereas moving
733
+ * `idpTokens` elsewhere would leave the next one to rediscover this.
734
+ *
735
+ * ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
736
+ * payload does not set them — so Better Auth still wins on everything it
737
+ * owns, and an explicit incoming value is never overridden by a stale one.
738
+ *
739
+ * NOT ATOMIC, and deliberately not: an enrichment landing between this
740
+ * read and this write is still lost. Closing that needs a WATCH/MULTI or
741
+ * a Lua script, which is a larger change than tonight allows. This
742
+ * narrows the window from "every single write" to "a genuine race", and
743
+ * the residual is recorded here rather than left to be re-found.
744
+ */
745
+ let payload = value;
746
+ try {
747
+ const existingRaw = await redis.get(redisKey);
748
+ if (existingRaw) {
749
+ const existing = JSON.parse(existingRaw);
750
+ const incoming = JSON.parse(value);
751
+ if (isPlainObject(existing) && isPlainObject(incoming)) {
752
+ let carried = false;
753
+ for (const preserved of PRESERVED_SESSION_KEYS) {
754
+ if (preserved in existing && !(preserved in incoming)) {
755
+ incoming[preserved] = existing[preserved];
756
+ carried = true;
757
+ }
758
+ }
759
+ if (carried) payload = JSON.stringify(incoming);
760
+ }
761
+ }
762
+ } catch (mergeErr) {
763
+ /**
764
+ * This adapter also stores `verification:*` and `active-sessions-*`
765
+ * records, which are not necessarily session-shaped JSON. A record we
766
+ * cannot parse is not a session to enrich, so writing through unchanged
767
+ * is correct rather than merely tolerable.
768
+ *
769
+ * WARNED, NOT THROWN, and the asymmetry is the point: this block is an
770
+ * ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
771
+ * degraded read into a lost session — the precise inversion 170033 fix 1
772
+ * above exists to prevent. The `setex` below is outside this try and is
773
+ * still unswallowed, so a genuine write failure still propagates.
774
+ */
775
+ console.warn(
776
+ `[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
777
+ `ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
778
+ `as-is. If this record held idpTokens they are now gone (273183):`,
779
+ mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
780
+ );
781
+ }
782
+
783
+ /**
784
+ * QA 6952 / R913 - CLAMP, DO NOT DEFER.
785
+ *
786
+ * This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
787
+ * source of session TTL" - what session-store.ts claims, and the mechanism
788
+ * by which the 7d-vs-3d zombie window was supposedly closed for good.
789
+ *
790
+ * IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
791
+ * eight secondaryStorage.set call sites in
792
+ * better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
793
+ * :180, :189, :309, :320, :344, :553, :622). `??` fires only on
794
+ * null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
795
+ * Auth's 7d default - `session.expiresIn` is not configured in this file -
796
+ * while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
797
+ * the zombie window the constant exists to prevent: the guard was bypassed
798
+ * by the very parameter written to defer to it.
799
+ *
800
+ * `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
801
+ * `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
802
+ * records whose TTLs are legitimately SHORTER than 3d. Overwriting those
803
+ * would EXTEND them - turning a fix for over-long sessions into a bug that
804
+ * keeps verification codes alive for three days. Clamp DOWN, never up.
805
+ *
806
+ * Clamped down to 3d rather than raising the canonical store to 7d because
807
+ * SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
808
+ * the refresh token sustaining it cannot be refreshed, so the extra days are
809
+ * a husk that reads as authenticated. Fail-closed beats ambiguous.
810
+ *
811
+ * DO NOT "fix" this by making storeIdpTokensInSession preserve Better
812
+ * Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
813
+ * universal. See test/ba-session-ttl-clamp.test.cjs.
814
+ */
815
+ await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
816
+ },
817
+ delete: async (key: string) => {
818
+ try {
819
+ await getRedis().del(`ba:${appSlug}:${key}`);
820
+ } catch (err) {
821
+ /**
822
+ * 170033 fix 2 — a failed delete must be VISIBLE, not `/* ignore *\/`.
823
+ *
824
+ * Deliberately logged rather than thrown, and the distinction is not
825
+ * timidity: the CANONICAL store is what decides session liveness (see
826
+ * 181922 below), and its delete runs immediately after this one and is
827
+ * NOT swallowed. So a BA record that outlives its canonical twin does
828
+ * not authenticate anyone — throwing here would fail a sign-out that
829
+ * actually succeeded where it counts.
830
+ *
831
+ * What was unacceptable is that it happened SILENTLY. A revocation
832
+ * that half-failed left no trace anywhere, so the leftover key could
833
+ * only ever be found by someone already reading the keyspace for
834
+ * another reason. `error`, not `warn`: this is a revocation that did
835
+ * not complete, not a retryable blip.
836
+ */
837
+ console.error(
838
+ `[BETTER_AUTH] secondary delete FAILED for ba:${appSlug}:${key} — ` +
839
+ `the better-auth session record may still exist. Canonical delete ` +
840
+ `below is what governs liveness; if it also failed, the session is ` +
841
+ `NOT revoked:`,
842
+ err
843
+ );
844
+ }
845
+ /**
846
+ * 181922 — teardown is BOTH stores. BA's sign-out deletes only its
847
+ * secondary record; the canonical {slug}:sess:{token} would survive
848
+ * and keep the session alive (a replayed cookie still authenticates).
849
+ * `key` here IS the session token (the ba: prefix is applied above),
850
+ * so the canonical delete is one call. This also fires on session
851
+ * rotation/expiry deletions, which is correct: a session BA drops
852
+ * must not live on in the canonical store.
853
+ */
854
+ try {
855
+ const { deleteSession } = await import('../lib/session-store');
856
+ await deleteSession(key);
857
+ } catch (err) {
858
+ console.warn('[BETTER_AUTH] secondary delete: canonical store cleanup failed:', err);
859
+ }
860
+ },
861
+ },
862
+
863
+ session: {
864
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
865
+ //
866
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
867
+ // a decoded session in process memory for 5 minutes, bypassing Redis
868
+ // reads during the window. Consequence: if the canonical app session
869
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
870
+ // Better Auth's in-memory copy stayed alive — and callers got
871
+ // contradictory answers depending on whether they consulted Better
872
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
873
+ // visible in production traces: viability 200 + idp-token 200 +
874
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
875
+ //
876
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
877
+ // read on the secondary-storage path. Latency cost is acceptable
878
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
879
+ // workarounds in every consumer app — already shipped in idealvibe.online
880
+ // at b6a91f6.
881
+ cookieCache: {
882
+ enabled: false,
883
+ maxAge: 300,
884
+ refreshCache: false,
885
+ },
886
+ },
887
+
888
+ // PAY-1781 B: KEEP OAUTH STATE IN THE ENCRYPTED COOKIE, as it was on better-auth 1.5.6.
889
+ // 1.5.6 resolved the default as `options.database ? 'database' : 'cookie'` - we have no
890
+ // database, so: cookie. 1.6.13+ resolves it as `(database || secondaryStorage) ? 'database'
891
+ // : 'cookie'`, and we DO have secondaryStorage, so the upgrade alone would silently move every
892
+ // OAuth state record into Redis through the adapter above (its enrichment read-modify-write
893
+ // and key handling were built for session records, not state). Pinned so the upgrade changes
894
+ // the version, not the design. The cookie branch is where GHSA-wxw3-q3m9-c3jr lived; 1.6.2+
895
+ // compares the returned `state` with the nonce sealed in the cookie, and our provider is also
896
+ // pkce:true, which the advisory required to be false. Test: oauth-state-strategy-cookie.
897
+ account: {
898
+ storeStateStrategy: 'cookie',
899
+ },
900
+
901
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
902
+ advanced: {
903
+ cookiePrefix: appSlug,
904
+ cookies: {
905
+ session_token: {
906
+ name: `${appSlug}.session-token`,
907
+ },
908
+ },
909
+ },
910
+
911
+ plugins: [
912
+ nextCookies(),
913
+ // Exchange-move: present ONLY when the IDP's OAuth endpoints are
914
+ // configured. Absent otherwise, so this cannot alter existing behaviour.
915
+ ...(idpOAuth ? [genericOAuth({ config: [idpOAuth] })] : []),
916
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
917
+ // App-supplied plugins last: they may depend on the built-ins above, and
918
+ // ordering is BA's own concern once they are in the array.
919
+ ...(opts.plugins ?? []),
920
+ ],
921
+ });
922
+ }
923
+
924
+ /**
925
+ * Better Auth is always enabled (NextAuth removed in 4.0).
926
+ */
927
+ export function isBetterAuthEnabled(): boolean {
928
+ return true;
929
+ }
930
+
931
+ /**
932
+ * Get Better Auth Next.js route handlers (GET, POST).
933
+ * Initializes Better Auth from IDP config on first call, caches the instance.
934
+ *
935
+ * STATE LIVES ON globalThis, NOT IN MODULE SCOPE (181814, measured 2026-08-07):
936
+ * under Turbopack, instrumentation.ts and the /api/auth/[...all] route are
937
+ * different chunks and each loads its OWN copy of this module. Module-level
938
+ * `configuredOpts` set by configureBetterAuth() in instrumentation never
939
+ * reached the copy getBetterAuthInstance() read in the route — measured
940
+ * identity probe: sameInstance=false, route-side plugins=["next-cookies"]
941
+ * while the instrumentation-side instance carried every configured plugin.
942
+ * No throw anywhere (the double-init guard never sees the other copy), core
943
+ * auth green, plugin routes 404. globalThis is per-process, per-runtime —
944
+ * exactly the scope this state must have.
945
+ */
946
+ interface BetterAuthModuleState {
947
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
948
+ cachedInstance: any;
949
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
950
+ initPromise: Promise<any> | null;
951
+ // PAY-642: the init attempt, single-flighted so a REJECTED attempt clears itself (see getBetterAuthInstance).
952
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
953
+ initFlight?: SingleFlight<any>;
954
+ configuredOpts: CreateBetterAuthInstanceOptions;
955
+ }
956
+
957
+ const STATE_KEY = '__payezBetterAuthState';
958
+ const g = globalThis as unknown as Record<string, BetterAuthModuleState | undefined>;
959
+ g[STATE_KEY] ??= { cachedInstance: null, initPromise: null, configuredOpts: {} };
960
+ const state = g[STATE_KEY];
961
+
962
+ // Expose for server-side session access (decode-session.ts). A getter so the
963
+ // export tracks the SHARED state, not a chunk-local snapshot.
964
+ export function __getBetterAuthInstance(): unknown {
965
+ return state.cachedInstance;
966
+ }
967
+
968
+ /**
969
+ * Configure Better Auth instance options for this process.
970
+ *
971
+ * Must be called before the first auth request — before
972
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
973
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
974
+ * server bootstrap hook.
975
+ *
976
+ * Throws if called after the instance has already been resolved: options
977
+ * cannot be applied retroactively.
978
+ */
979
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
980
+ if (state.cachedInstance) {
981
+ throw new Error(
982
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
983
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
984
+ );
985
+ }
986
+ state.configuredOpts = opts;
987
+ }
988
+
989
+ /**
990
+ * PAY-642 (card 338215): the init attempt no longer LATCHES A FAILURE.
991
+ *
992
+ * This used to be `state.initPromise ??= getIDPClientConfig(true).then(...)` with no .catch and no reset. One
993
+ * auth request against a cold process while the IdP was unreachable stored a rejected promise, and every later
994
+ * caller replayed it until the process restarted, even after the IdP recovered. This funnel backs every
995
+ * /api/auth/* route, the login GET handler and withAuth. server/auth.ts getAuthInstance() already used
996
+ * createSingleFlight for this exact bug; this sibling funnel was missed.
997
+ *
998
+ * Now the attempt runs through the same helper: concurrent callers share one attempt, a failure still rejects
999
+ * for every caller of THAT attempt (loud), the next call starts a fresh attempt, and a success is cached in
1000
+ * state.cachedInstance as before. The flight lives on the globalThis state for the same reason the instance
1001
+ * does (duplicated route chunks must share one init).
1002
+ */
1003
+ export async function getBetterAuthInstance() {
1004
+ if (state.cachedInstance) return state.cachedInstance;
1005
+
1006
+ state.initFlight ??= createSingleFlight(() =>
1007
+ getIDPClientConfig(true).then(config => {
1008
+ const instance = createBetterAuthInstance(config, state.configuredOpts);
1009
+ state.cachedInstance = instance;
1010
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
1011
+ return instance;
1012
+ })
1013
+ );
1014
+
1015
+ const attempt = state.initFlight.get();
1016
+ // Kept for callers that inspect it; it always points at the CURRENT attempt, never a settled failure.
1017
+ state.initPromise = attempt;
1018
+ return attempt;
1019
+ }
1020
+
1021
+ /**
1022
+ * Get flag-gated auth handler for Next.js route.
1023
+ *
1024
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
1025
+ * Otherwise returns null (auth disabled).
1026
+ *
1027
+ * Usage in host app route:
1028
+ * ```ts
1029
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
1030
+ *
1031
+ * export async function GET(req: Request) {
1032
+ * const ba = await getBetterAuthHandler();
1033
+ * if (ba) return ba.GET(req);
1034
+ * }
1035
+ * ```
1036
+ */
1037
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
1038
+ if (!isBetterAuthEnabled()) return null;
1039
+
1040
+ const auth = await getBetterAuthInstance();
1041
+ // PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
1042
+ // token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
1043
+ // auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
1044
+ return wrapBetterAuthHandler(auth as any, toNextJsHandler(auth));
1045
+ }
1046
+
1047
+ /**
1048
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
1049
+ *
1050
+ * Call this from the OAuth callback route AFTER better-auth has processed the
1051
+ * callback and created the session. Reads the session token from the Set-Cookie
1052
+ * header of the response to find the BA Redis key.
1053
+ *
1054
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
1055
+ *
1056
+ * RETURNS the IdP's own error code on an IDP refusal, or null otherwise
1057
+ * (PAY-2005 scenario (b), BAPert ruling 67897). It used to return a bare
1058
+ * boolean, so `REGISTRATION_NOT_ALLOWED` was indistinguishable from a network
1059
+ * blip and every cause collapsed to one generic login-page code — a NEW user
1060
+ * refused registration bounced silently, which is the failure Jon named.
1061
+ */
1062
+ export async function exchangeOAuthForIdpTokens(
1063
+ sessionToken: string,
1064
+ provider: string = 'google'
1065
+ ): Promise<boolean | string> {
1066
+ try {
1067
+ const config = await getIDPClientConfig();
1068
+ const appSlug = config.clientSlug || getAppSlug();
1069
+ const baKey = `ba:${appSlug}:${sessionToken}`;
1070
+
1071
+ // Read the BA session from Redis
1072
+ const baRaw = await getRedis().get(baKey).catch(() => null);
1073
+ if (!baRaw) {
1074
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', tokenLogId(sessionToken));
1075
+ return false;
1076
+ }
1077
+
1078
+ const baData = JSON.parse(baRaw);
1079
+ const email = baData?.user?.email;
1080
+ const name = baData?.user?.name;
1081
+ const image = baData?.user?.image;
1082
+ const baUserId = baData?.session?.userId || baData?.user?.id;
1083
+
1084
+ if (!email) {
1085
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
1086
+ return false;
1087
+ }
1088
+
1089
+ // Call IDP oauth-callback
1090
+ const idpUrl = process.env.IDP_URL || '';
1091
+ if (!idpUrl) {
1092
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
1093
+ return false;
1094
+ }
1095
+
1096
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
1097
+
1098
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
1099
+ method: 'POST',
1100
+ headers: { 'Content-Type': 'application/json' },
1101
+ body: JSON.stringify({
1102
+ provider,
1103
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
1104
+ email,
1105
+ name,
1106
+ image,
1107
+ client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? '',
1108
+ /**
1109
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
1110
+ *
1111
+ * Every other field in this body is an ASSERTION. The endpoint mints an
1112
+ * IDP bearer from them today with no cryptographic check of any kind,
1113
+ * which is why it is a complete authentication bypass and why it has
1114
+ * been publicly reachable for 488 days.
1115
+ *
1116
+ * This field is the only one the IDP can VERIFY: signature against
1117
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
1118
+ * from the verified claims. Additive — the IDP ignores it until its
1119
+ * gate flips, so this cannot break a login on its own.
1120
+ *
1121
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
1122
+ * decoded email_verified. A client-computed verification flag is a body
1123
+ * field with a better story: an attacker posts email_verified:true and
1124
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
1125
+ * the fix is theatre.
1126
+ *
1127
+ * Absent when this runs outside the OAuth callback scope — the key is
1128
+ * simply omitted rather than sent null, so "we never captured one" stays
1129
+ * distinguishable from "the provider issued none".
1130
+ */
1131
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
1132
+ /**
1133
+ * The same field for providers that have no id_token to give.
1134
+ *
1135
+ * Identical doctrine to the id_token above: RAW, forwarded verbatim,
1136
+ * NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
1137
+ * "never captured" stays distinguishable from "provider issued none".
1138
+ * The IDP verifies for itself by calling GitHub with this token and
1139
+ * enforcing primary && verified; we assert nothing about the identity.
1140
+ *
1141
+ * Scoped to the provider access token captured during THIS callback, so
1142
+ * a google login continues to send id_token only and is unaffected.
1143
+ */
1144
+ ...(idTokenScope.getStore()?.accessToken
1145
+ ? { access_token: idTokenScope.getStore()!.accessToken }
1146
+ : {}),
1147
+ }),
1148
+ });
1149
+
1150
+ const oauthResText = await oauthRes.text();
1151
+ // PAY-1427 (QAPert, PR 154 thread 326): this printed the first 500 chars of the oauth-callback TOKEN
1152
+ // envelope on EVERY successful OAuth login (it runs before the !ok check). Status + length only.
1153
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', { status: oauthRes.status, bodyLength: oauthResText.length });
1154
+
1155
+ if (!oauthRes.ok) {
1156
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
1157
+
1158
+ /**
1159
+ * PAY-2005 scenario (b) — RETURN THE IDP'S ERROR CODE (BAPert ruling 67897).
1160
+ *
1161
+ * Every refusal used to return a bare `false`, so the abort collapsed ALL
1162
+ * causes to one generic `OAUTH_SIGN_IN_FAILED`: a NEW user refused
1163
+ * registration (`REGISTRATION_NOT_ALLOWED`, ExternalAuthController.cs:908)
1164
+ * bounced silently, indistinguishable from a network blip. Jon named the
1165
+ * clear message explicitly (67856): "(b) a brand-new Google user with
1166
+ * registration OFF is refused by the IDP with a clear message, not a
1167
+ * silent bounce."
1168
+ *
1169
+ * The envelope is `{ error: { code, message } }` (PayEzControllerBase.Error).
1170
+ * We read ONLY `error.code` and return it VERBATIM — the abort puts it in
1171
+ * the `?error=` param, and the HOST's allowlist decides whether to render
1172
+ * it. The package does NOT interpret the code and does NOT add a message
1173
+ * (that would be a second allowlist). No code in the body -> null (the
1174
+ * generic host code), exactly as before.
1175
+ */
1176
+ let idpErrorCode: string | null = null;
1177
+ try {
1178
+ const parsed = JSON.parse(oauthResText);
1179
+ // FORWARD ONLY AN ALLOWLISTED CODE. Every other refusal keeps PAY-1733's single neutral code — an
1180
+ // unlisted code would render nothing on the host page, which is the silent bounce this exists to end.
1181
+ if (typeof parsed?.error?.code === 'string' && IDP_ERROR_CODES_TO_FORWARD.has(parsed.error.code)) {
1182
+ idpErrorCode = parsed.error.code;
1183
+ }
1184
+ } catch {
1185
+ /* non-JSON refusal body -> no code (generic abort) */
1186
+ }
1187
+
1188
+ /**
1189
+ * WHY THIS LOGS ONLY ON FAILURE, AND WHY IT LOGS THESE FIELDS.
1190
+ *
1191
+ * UNVERIFIED_ASSERTION has exactly two causes and the response body cannot tell
1192
+ * them apart: we sent no id_token for the IDP to verify, or we sent one whose
1193
+ * signature/aud/iss the IDP rejected. Without this line the operator sees a 401 on
1194
+ * a login that visibly succeeded and has nowhere to go — which is where UMI sat on
1195
+ * 2026-08-26.
1196
+ *
1197
+ * NEVER THE TOKEN ITSELF. An id_token is credential material and this whole code
1198
+ * path exists to keep it out of anything persistent; a log file is persistent. We
1199
+ * print presence, length, and the three claims that decide verification. `aud` is
1200
+ * the Google client id — not a secret, and the single most likely mismatch, since
1201
+ * the IDP checks it against what IT has registered for this client.
1202
+ */
1203
+ const forwarded = idTokenScope.getStore()?.idToken;
1204
+ let claims: Record<string, unknown> = {};
1205
+ if (forwarded) {
1206
+ try {
1207
+ claims = decodeJwt(forwarded) as Record<string, unknown>;
1208
+ } catch {
1209
+ claims = { decode: 'FAILED — not a well-formed JWT' };
1210
+ }
1211
+ }
1212
+ const forwardedAccess = idTokenScope.getStore()?.accessToken;
1213
+ console.error('[BETTER_AUTH] exchange diagnostics:', {
1214
+ id_token_forwarded: !!forwarded,
1215
+ id_token_length: forwarded ? forwarded.length : 0,
1216
+ /**
1217
+ * Read these two for github, and IGNORE the id_token pair above: github
1218
+ * issues no id_token, so `id_token_forwarded: false` is its NORMAL state
1219
+ * and not the fault. Presence and length only — an access token is
1220
+ * credential material and this log is persistent. A github failure with
1221
+ * access_token_forwarded:true means the IDP called GitHub and refused
1222
+ * the answer (unverified or non-primary email, or app not installed);
1223
+ * false means the capture hook never ran and the IDP never called out.
1224
+ */
1225
+ access_token_forwarded: !!forwardedAccess,
1226
+ access_token_length: forwardedAccess ? forwardedAccess.length : 0,
1227
+ // Absent id_token => the IDP had nothing to verify. Present => compare these
1228
+ // against the client's registered Google credentials on the IDP.
1229
+ aud: claims.aud ?? null,
1230
+ iss: claims.iss ?? null,
1231
+ email_verified: claims.email_verified ?? null,
1232
+ sent_client_id: resolveClientKey(config, 'auth/better-auth:oauth-callback').key ?? null,
1233
+ idp_url: idpUrl,
1234
+ });
1235
+ return idpErrorCode ?? false;
1236
+ }
1237
+
1238
+ let idpData: any;
1239
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
1240
+ const result = idpData?.data?.result || idpData?.data || idpData;
1241
+
1242
+ if (!result?.access_token) {
1243
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
1244
+ return false;
1245
+
1246
+ }
1247
+
1248
+ // Build IDP token data
1249
+ //
1250
+ // 170007 CONTRACT (BAPert ruling on 121350, agreed with DotNetPert
1251
+ // 2026-08-08 — see mail thread, not re-derived here):
1252
+ // 1. The IDP emits `requires_two_factor` (snake_case, both serializers,
1253
+ // per the estate law) as a NON-NULLABLE bool on OAuthCallbackResponse
1254
+ // (PayEz.DTOs OAuthCallbackResponse.cs) — ALWAYS present. No IDP
1255
+ // change needed or wanted: emitting a second, camelCase name for the
1256
+ // same fact would be the dialect problem the estate law exists to
1257
+ // prevent, not a fix.
1258
+ // 2. Read requires_two_factor (snake) ONLY. CORRECTED mid-review:
1259
+ // BAPert's first draft of this contract said "read both casings,
1260
+ // defensively" — DotNetPert rejected that: a dual-casing client-side
1261
+ // read gives one fact two wire names just as surely as a dual-casing
1262
+ // IDP emit would, and it is the exact audience-dialect mistake
1263
+ // (186443) made deliberately instead of by accident. This package
1264
+ // previously read camel ONLY (card 170083, now corrected — it must
1265
+ // NOT record "emit both casings" as the IDP-side fix), which is why
1266
+ // this line silently fell through to the heuristic below on every
1267
+ // real call. The fix is reading the one name the IDP actually sends,
1268
+ // not tolerating both.
1269
+ // 3. The old !hasRefreshToken fallback (IDP-2FA-BUG below) is RETIRED,
1270
+ // not patched. DotNetPert traced ExternalAuthController's
1271
+ // oauth-callback: IssueRefreshToken and RequiresTwoFactor both derive
1272
+ // from the SAME isFullyAuthenticated variable, so they cannot
1273
+ // disagree on this path — the divergence this fallback existed for
1274
+ // is not present here. Worse, the heuristic would currently produce
1275
+ // the RIGHT answer by coincidence, which hides the day it stops
1276
+ // being derived from one variable.
1277
+ // 4. requires_two_factor is non-nullable, so reaching the `true`
1278
+ // default below means the response ISN'T an OAuthCallbackResponse at
1279
+ // all (wrong endpoint / error body / failed parse) — logged as that,
1280
+ // not as "2FA status unknown". Fail-closed to requiresTwoFactor=true
1281
+ // either way (rule 3).
1282
+ // 5. mfaVerified = !requiresTwoFactor, computed fresh from THIS
1283
+ // response — no hardcoding, no carrying a prior session value
1284
+ // forward (that half of the monotonic-OR defect lived in
1285
+ // refresh.ts / ensure-fresh-access-token.ts, fixed separately).
1286
+ //
1287
+ // PRIOR STATE, for the record: this used to hardcode mfaVerified=false on
1288
+ // every OAuth exchange as a blunt workaround for a since-disproven IDP
1289
+ // bug, which itself caused every Google user to be sent to /mfa even on
1290
+ // require_2fa=FALSE tenants (NextPert 13450's blocker, failing T4 on the
1291
+ // demo tenant). That workaround is removed along with the bug it worked
1292
+ // around.
1293
+ const requiresTwoFactor: boolean = (() => {
1294
+ // SNAKE ONLY (BAPert's corrected ruling, 2026-08-08): reading camel as
1295
+ // a "defensive" secondary would give one fact two wire names — exactly
1296
+ // the audience-dialect mistake (186443) being made deliberately. The
1297
+ // estate law is explicit-snake-per-field-both-serializers; the IDP
1298
+ // already complies (OAuthCallbackResponse.cs, both System.Text.Json
1299
+ // and Newtonsoft). No IDP change, no camel read, no dual-casing.
1300
+ const v = result.requires_two_factor ?? result.user?.requires_two_factor;
1301
+ if (v === undefined) {
1302
+ console.warn(
1303
+ '[BETTER_AUTH] oauth-callback response has no requires_two_factor field at all — ' +
1304
+ 'unexpected response shape (requires_two_factor is non-nullable on OAuthCallbackResponse; ' +
1305
+ 'this is not "2FA status unknown", it means we are not looking at an oauth-callback response). ' +
1306
+ 'Defaulting requiresTwoFactor=true (fail closed).',
1307
+ );
1308
+ return true;
1309
+ }
1310
+ return !!v;
1311
+ })();
1312
+ const idpTokenData = {
1313
+ idpAccessToken: result.access_token,
1314
+ idpRefreshToken: result.refresh_token,
1315
+ idpAccessTokenExpires: result.expires_in
1316
+ ? Date.now() + result.expires_in * 1000
1317
+ : Date.now() + 15 * 60 * 1000,
1318
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
1319
+ email: result.user?.email || result.email || email,
1320
+ name: result.user?.full_name || result.user?.name || result.name || name,
1321
+ image: image,
1322
+ roles: result.user?.roles || result.roles || [],
1323
+ mfaVerified: !requiresTwoFactor, // re-derived fresh — see 170007 contract above
1324
+ // Cards 141688/141741 — RELAYED, NOT DERIVED.
1325
+ //
1326
+ // The IDP already decides this (ExternalAuthController:943 and :2024,
1327
+ // WO 113023): "enroll_mfa" = no CONFIRMED method, "mfa_verify" = a
1328
+ // confirmed method exists, null = fully authenticated. It was emitted
1329
+ // here and dropped on this line — `idpTokenData` is built field-by-field,
1330
+ // so anything not named is silently discarded. Both downstream consumers
1331
+ // (the middleware gate and the verify-code page) then reasoned in its
1332
+ // absence and sent zero-method users to a page they cannot act on.
1333
+ //
1334
+ // WIRE NAME IS SNAKE: `next_step`, per [JsonPropertyName("next_step")] on
1335
+ // BOTH OAuthCallbackResponse and ExternalTokenResponse. Reading
1336
+ // `result.nextStep` here would be `undefined` forever and fail silently —
1337
+ // the same camel/snake break `requires_two_factor` already cost us once.
1338
+ //
1339
+ // Do NOT recompute this from a method query. The IDP owns the fact; it is
1340
+ // already usable-aware (confirmed and not revoked, per DotNetPert), which
1341
+ // is the distinction that decides correctness: a revoked-TOTP user has
1342
+ // count>0 but usable=0, and deriving it locally would let through exactly
1343
+ // the users we must challenge.
1344
+ // No `?? null`: if the response has no `next_step` at all (older IDP
1345
+ // build, or a path that does not set it), that is UNKNOWN, not "fully
1346
+ // authenticated". Defaulting it to null here would manufacture a
1347
+ // clean-bill-of-health the IDP never gave and fail open at the gate.
1348
+ nextStep: result.next_step,
1349
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
1350
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
1351
+ };
1352
+
1353
+ // Storage is shared with the passkey sign-in path — see storeIdpTokensInSession.
1354
+ const stored = await storeIdpTokensInSession(sessionToken, idpTokenData, provider);
1355
+ if (stored) console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
1356
+ // true = success (tokens stored). false = a non-IdP failure with no specific code (generic abort).
1357
+ return stored;
1358
+ } catch (err) {
1359
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
1360
+ return false;
1361
+ }
1362
+ }
1363
+
1364
+
1365
+ /**
1366
+ * Write IDP tokens into an EXISTING Better Auth session. Storage only — this
1367
+ * function never acquires tokens and never decides who the caller is.
1368
+ *
1369
+ * ── WHY THIS IS SPLIT OUT (BAPert ruling, 2026-08-04) ────────────────────────
1370
+ *
1371
+ * `exchangeOAuthForIdpTokens` above does TWO things: it ACQUIRES tokens by
1372
+ * POSTing to `/api/ExternalAuth/oauth-callback`, and then it STORES them. Only
1373
+ * the second half is reusable, and the first half is actively dangerous to
1374
+ * reuse:
1375
+ *
1376
+ * oauth-callback mints an IDP bearer from an ASSERTED EMAIL with no
1377
+ * cryptographic verification of any kind (card 138635).
1378
+ *
1379
+ * Passkey sign-in already holds something strictly better — a grant issued by
1380
+ * `/api/ExternalAuth/passwordless/passkey/verify`, which checked a real
1381
+ * signature against a registered credential. Routing that through
1382
+ * oauth-callback to obtain a session would DOWNGRADE a cryptographically
1383
+ * verified assertion into an unverified one, make passkey sign-in weaker than
1384
+ * its own ceremony, and inherit 138635 wholesale.
1385
+ *
1386
+ * That is not hypothetical: "do what the OAuth path does" is the obvious
1387
+ * implementation and it is the wrong one. Splitting acquisition from storage is
1388
+ * what makes the wrong one hard to write by accident.
1389
+ *
1390
+ * ── WHAT CALLERS MUST HAVE DONE FIRST ───────────────────────────────────────
1391
+ *
1392
+ * A Better Auth session must ALREADY EXIST for `sessionToken`. This function
1393
+ * enriches; it does not create. Better Auth owns session creation and cookie
1394
+ * signing, and nothing in this package should replicate either — the cookie is
1395
+ * `{token}.{base64(hmac)}` with standard padding, signed with the runtime
1396
+ * `authSecret` from Redis, and hand-rolling that produces a session which
1397
+ * decodes but does not authenticate.
1398
+ *
1399
+ * @param sessionToken the Better Auth session token (from its Set-Cookie)
1400
+ * @param idpTokenData tokens + identity, already obtained by the CALLER'S OWN
1401
+ * verified route. Built field-by-field by the caller so an
1402
+ * unnamed field cannot ride along silently.
1403
+ * @param authProvider provenance label recorded on the canonical row. Written
1404
+ * to the historical `oauthProvider` key, which predates
1405
+ * non-OAuth callers; the NAME is legacy, the VALUE should
1406
+ * state what actually authenticated ('passkey', 'google').
1407
+ */
1408
+ export async function storeIdpTokensInSession(
1409
+ sessionToken: string,
1410
+ idpTokenData: Record<string, unknown>,
1411
+ authProvider: string,
1412
+ ): Promise<boolean> {
1413
+ try {
1414
+ const config = await getIDPClientConfig();
1415
+ const appSlug = config.clientSlug || getAppSlug();
1416
+ const baKey = `ba:${appSlug}:${sessionToken}`;
1417
+
1418
+ const baRaw = await getRedis().get(baKey).catch(() => null);
1419
+ if (!baRaw) {
1420
+ // No session to enrich. Do NOT create one as a fallback: a session
1421
+ // conjured here would carry no Better Auth cookie, so it would be
1422
+ // unreachable by the browser and invisible to every other consumer —
1423
+ // failing as "silently signed out" rather than as an error.
1424
+ console.warn('[BETTER_AUTH] storeIdpTokensInSession: no BA session for token', tokenLogId(sessionToken));
1425
+ return false;
1426
+ }
1427
+
1428
+ const baData = JSON.parse(baRaw);
1429
+ baData.idpTokens = idpTokenData;
1430
+ await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
1431
+
1432
+ // Canonical store — the key getSession() reads, used by the refresh handler
1433
+ // and token lifecycle. A failure here leaves the BA session correct and the
1434
+ // canonical row missing, so it is warned and not fatal: the user is signed
1435
+ // in, and refresh degrades rather than the sign-in failing outright.
1436
+ try {
1437
+ const { getSessionPrefix } = await import('../lib/app-slug');
1438
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
1439
+ await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
1440
+ ...idpTokenData,
1441
+ oauthProvider: authProvider,
1442
+ }));
1443
+ } catch (canonicalErr) {
1444
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
1445
+ }
1446
+
1447
+ return true;
1448
+ } catch (err) {
1449
+ console.error('[BETTER_AUTH] storeIdpTokensInSession failed:', err instanceof Error ? err.message : String(err));
1450
+ return false;
1451
+ }
1452
+ }
1453
+
1454
+ /**
1455
+ * The `?error=` code the login page receives when an OAuth sign-in is ABORTED because the IdP exchange did not
1456
+ * store tokens (PAY-1733). Hosts map it to ONE neutral line; the card's words are "That sign-in didn't work.
1457
+ * Try again or use another method." The cause stays in the server log, never on the page.
1458
+ */
1459
+ export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
1460
+
1461
+ /**
1462
+ * PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
1463
+ * verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
1464
+ * purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
1465
+ */
1466
+ export async function storePayezIdpSession(sessionToken: string): Promise<boolean> {
1467
+ const captured = idTokenScope.getStore()?.idp;
1468
+ if (!captured?.tokens?.accessToken || !captured.claims) {
1469
+ console.warn('[BETTER_AUTH] payez-idp callback: no verified IdP tokens captured for this request');
1470
+ return false;
1471
+ }
1472
+ const { tokens, claims } = captured;
1473
+ const stored = await storeIdpTokensInSession(
1474
+ sessionToken,
1475
+ {
1476
+ idpAccessToken: tokens.accessToken,
1477
+ idpRefreshToken: tokens.refreshToken,
1478
+ idpAccessTokenExpires: tokens.accessTokenExpires ?? Date.now() + 15 * 60 * 1000,
1479
+ userId: String(claims.userId ?? claims.sub),
1480
+ email: claims.email,
1481
+ name: claims.name,
1482
+ roles: claims.roles,
1483
+ mfaVerified: claims.mfaVerified,
1484
+ nextStep: undefined,
1485
+ },
1486
+ 'payez-idp',
1487
+ );
1488
+ if (stored) console.log('[BETTER_AUTH] IdP tokens stored in session for', claims.email);
1489
+ return stored;
1490
+ }
1491
+
1492
+ /**
1493
+ * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
1494
+ *
1495
+ * Default stays PAY-1733's ONE neutral code: the cause of an ordinary failed exchange lives in the server
1496
+ * log, never on the page. A code is forwarded ONLY when it earns a distinct user-facing message — today just
1497
+ * the registration refusal, so a NEW Google user with registration OFF sees a clear "not open to sign-up"
1498
+ * line instead of a silent bounce. Adding a code here REQUIRES a matching entry in the host allowlist
1499
+ * (portal: src/lib/auth/magic-link-outcome.ts); an unlisted code renders nothing, which is a silent bounce
1500
+ * wearing a different number. Add them together, never one alone.
1501
+ */
1502
+ export const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string> = new Set(['REGISTRATION_NOT_ALLOWED']);
1503
+
1504
+
1505
+ /**
1506
+ * Re-emit each cookie the callback response SET as an immediate expiry, keeping the attributes that decide
1507
+ * WHICH cookie the browser overwrites (Path, Domain, Secure, SameSite, HttpOnly - a `__Secure-` name is only
1508
+ * accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
1509
+ */
1510
+ export function expireSetCookies(setCookies: readonly string[]): string[] {
1511
+ const out: string[] = [];
1512
+ for (const sc of setCookies) {
1513
+ const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
1514
+ const eq = pair.indexOf('=');
1515
+ const name = eq > 0 ? pair.slice(0, eq) : '';
1516
+ if (!name) continue;
1517
+ const kept = attrs.filter((a) => /^(path|domain|secure|samesite|httponly)\b/i.test(a));
1518
+ out.push([`${name}=`, ...kept, 'Max-Age=0'].join('; '));
1519
+ }
1520
+ return out;
1521
+ }
1522
+
1523
+ /**
1524
+ * PAY-1733 (Jon, live on :4797): ABORT an OAuth sign-in whose IdP exchange did not store tokens.
1525
+ *
1526
+ * Before this, a failed exchange was a console.error and the user kept the Better Auth session the callback had
1527
+ * just created: signed in, holding NO IdP tokens. The host then routed that session to 2FA, which painted the
1528
+ * user's email and failed every call - a red "sign in again" screen about an account the app could no longer
1529
+ * authenticate. The IdP's 401 was CORRECT (it fail-closes unverified assertions); keeping the session was the bug.
1530
+ *
1531
+ * So: no session without IdP tokens. The Better Auth session is destroyed (its own signOut, best effort, then the
1532
+ * `ba:<slug>:<token>` record directly - that record is what makes it resolvable), every cookie the callback set is
1533
+ * expired, and the user goes to the login page with OAUTH_SIGN_IN_FAILED. Relative Location, as for the state-error
1534
+ * branch above (328439: an absolute URL built from request.url is a dead tab in a container).
1535
+ */
1536
+ async function abortOAuthSignIn(
1537
+ callbackResponse: Response,
1538
+ sessionToken: string | null,
1539
+ cookieHeader: string,
1540
+ loginPath: string,
1541
+ errorCode: string = OAUTH_SIGN_IN_FAILED_ERROR,
1542
+ ): Promise<Response> {
1543
+ const headers = new Headers();
1544
+ if (sessionToken) {
1545
+ try {
1546
+ const auth = await getBetterAuthInstance();
1547
+ const out = await auth?.api?.signOut?.({ headers: new Headers({ cookie: cookieHeader }), asResponse: true });
1548
+ for (const sc of (out as Response | undefined)?.headers?.getSetCookie?.() ?? []) headers.append('set-cookie', sc);
1549
+ } catch (err) {
1550
+ console.warn('[BETTER_AUTH] abort: Better Auth signOut failed, deleting the record directly:', err instanceof Error ? err.message : String(err));
1551
+ }
1552
+ try {
1553
+ const config = await getIDPClientConfig();
1554
+ await getRedis().del(`ba:${config.clientSlug || getAppSlug()}:${sessionToken}`);
1555
+ } catch (err) {
1556
+ console.error('[BETTER_AUTH] abort: could not delete the Better Auth session record', tokenLogId(sessionToken), err instanceof Error ? err.message : String(err));
1557
+ }
1558
+ }
1559
+ for (const sc of expireSetCookies(callbackResponse.headers.getSetCookie?.() ?? [])) headers.append('set-cookie', sc);
1560
+ headers.set('location', `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${errorCode}`);
1561
+ console.warn('[BETTER_AUTH] OAuth sign-in ABORTED: the IdP exchange stored no tokens, so no session is kept (PAY-1733)', sessionToken ? tokenLogId(sessionToken) : '(no session)', 'code=' + errorCode);
1562
+ return new Response(null, { status: 302, headers });
1563
+ }
1564
+
1565
+ /**
1566
+ * Create a production-ready GET handler for the auth catch-all route.
1567
+ *
1568
+ * Wraps better-auth's GET handler with:
1569
+ * - OAuth state error recovery (redirects to login instead of error page)
1570
+ * - IDP token exchange after successful OAuth callback - and, when it stores no tokens, the sign-in is ABORTED
1571
+ * (session destroyed, cookies expired, login page with OAUTH_SIGN_IN_FAILED; PAY-1733)
1572
+ *
1573
+ * Usage in host app:
1574
+ * ```ts
1575
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
1576
+ * export const GET = createAuthGetHandler('/account-auth/login');
1577
+ * export async function POST(req: Request) {
1578
+ * const ba = await getBetterAuthHandler();
1579
+ * return ba!.POST(req);
1580
+ * }
1581
+ * ```
1582
+ */
1583
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
1584
+ return async function GET(request: Request): Promise<Response> {
1585
+ // Card 138635: establish the request-scoped carrier for the provider's raw
1586
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
1587
+ // hook that sees the token) and the exchange below (which forwards it).
1588
+ // Wrapping the whole handler is what makes "never persisted" achievable —
1589
+ // the token exists only for the life of this request.
1590
+ return idTokenScope.run({}, async () => {
1591
+ const ba = await getBetterAuthHandler();
1592
+ if (!ba) {
1593
+ return new Response('Auth handler not configured', { status: 500 });
1594
+ }
1595
+
1596
+ const response = await ba.GET(request);
1597
+
1598
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
1599
+ if (response.status === 302) {
1600
+ const location = response.headers.get('location') || '';
1601
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
1602
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
1603
+ // *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
1604
+ // Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
1605
+ // runtime and again on node 24:
1606
+ // Response.redirect('/account-auth/login', 302)
1607
+ // -> TypeError: Failed to parse URL from /account-auth/login
1608
+ // That requirement is the whole cause of the bug this replaces. The old line
1609
+ // was:
1610
+ // return Response.redirect(new URL(loginPath, request.url), 302);
1611
+ // `request.url` inside a containerised Next standalone server carries the
1612
+ // LISTEN address, not the address the browser used, so a container bound to
1613
+ // 0.0.0.0:8080 handed the browser `http://0.0.0.0:8080/sign-in` — an address
1614
+ // that can NEVER load (net::ERR_ADDRESS_INVALID, confirmed in a real browser
1615
+ // by QAPert). Reload cannot recover it, because the address is invalid rather
1616
+ // than down. It fired on the ORDINARY path: the oauth_state cookie is
1617
+ // Max-Age=600, so anyone spending >10 minutes at Google's account chooser
1618
+ // landed on a dead tab.
1619
+ //
1620
+ // A RELATIVE Location header is legal (RFC 7231 §7.1.2) and the browser
1621
+ // resolves it against the origin it is ACTUALLY on, so no origin has to be
1622
+ // guessed, configured, or derived from a header. This is the same shape the
1623
+ // app's own middleware already emits successfully (307 + relative), which is
1624
+ // why page redirects survived while this one did not.
1625
+ //
1626
+ // WHY NOT REBUILD FROM BETTER_AUTH_URL / rawBaseURL, the other proposal:
1627
+ // (a) rawBaseURL is NOT in scope here — it is a local inside
1628
+ // createBetterAuthInstance (~:397), a different function ~676 lines up.
1629
+ // Reaching it means recomputing the env precedence or exporting a
1630
+ // helper, i.e. more moving parts for a strictly worse result.
1631
+ // (b) It would reintroduce a guessed origin and make this path depend on
1632
+ // BETTER_AUTH_URL being set correctly per leg. Relative needs no env.
1633
+ // (c) Anything Host-derived would open host-header injection on a redirect
1634
+ // that is currently host-INVARIANT. Relative keeps that property.
1635
+ //
1636
+ // Works whether loginPath is relative (package default '/account-auth/login',
1637
+ // and the graft passes '/sign-in') or absolute — an absolute Location is also
1638
+ // legal, so a consumer passing a full URL still behaves.
1639
+ return new Response(null, { status: 302, headers: { location: loginPath } });
1640
+ }
1641
+ }
1642
+
1643
+ // After the OAuth callback: exchange the provider identity for IDP tokens. If that does NOT store tokens - the
1644
+ // IdP refused (401), no session was found, or anything threw - the sign-in is ABORTED (PAY-1733). A session is
1645
+ // kept only when it holds IdP tokens.
1646
+ const url = new URL(request.url);
1647
+ if (url.pathname.includes('/callback/') && response.status === 302) {
1648
+ const setCookies = response.headers.getSetCookie?.() || [];
1649
+ const cookieHeader = setCookies
1650
+ .map((c: string) => c.split(';')[0])
1651
+ .join('; ');
1652
+ let sessionToken: string | null = null;
1653
+ // true = tokens stored; false = no specific IdP code (generic abort); a string = the IdP's own refusal code
1654
+ // (PAY-2005 (b): REGISTRATION_NOT_ALLOWED, etc.), forwarded so the host can show a clear message.
1655
+ let exchange: boolean | string = false;
1656
+ try {
1657
+ const auth = await getBetterAuthInstance();
1658
+ if (auth?.api?.getSession) {
1659
+ const headers = new Headers();
1660
+ headers.set('cookie', cookieHeader);
1661
+
1662
+ const session = await auth.api.getSession({ headers });
1663
+ if (session?.session?.token) {
1664
+ sessionToken = session.session.token;
1665
+ console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
1666
+ // PAY-2305: the payez-idp callback already holds an IdP-ISSUED bearer (verified in getUserInfo). It must NOT go
1667
+ // through oauth-callback, the asserted-email path (card 138635), and storing it is what the passkey path does.
1668
+ exchange = url.pathname.endsWith('/callback/payez-idp')
1669
+ ? await storePayezIdpSession(session.session.token)
1670
+ : await exchangeOAuthForIdpTokens(session.session.token);
1671
+ } else {
1672
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
1673
+ }
1674
+ }
1675
+ } catch (err: any) {
1676
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
1677
+ }
1678
+ if (exchange !== true) {
1679
+ // A specific IdP error code goes on the URL; otherwise the generic OAUTH_SIGN_IN_FAILED.
1680
+ const errorCode = typeof exchange === 'string' ? exchange : OAUTH_SIGN_IN_FAILED_ERROR;
1681
+ return abortOAuthSignIn(response, sessionToken, cookieHeader, loginPath, errorCode);
1682
+ }
1683
+ }
1684
+
1685
+ return response;
1686
+ });
1687
+ };
1688
+ }