@payez/next-mvp 4.8.1 → 4.8.3

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 (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
@@ -1,310 +1,310 @@
1
- /**
2
- * PAY-2305 (next-mvp 4.7.4): the two hooks that let better-auth's genericOAuth talk to THIS IdP.
3
- *
4
- * WHY THEY EXIST (source read, PayEz-Core master d53f192af, BAPert 74014):
5
- * - POST /api/oauth/token takes a JSON body (snake_case), not better-auth's default form body, and answers with the
6
- * Core response envelope { success, data: { access_token, refresh_token, token_type, expires_in, user } }.
7
- * better-auth's default exchange reads top-level fields and would find nothing.
8
- * - There is NO id_token and NO userinfo endpoint (deliberate, PAY-1585). The only proof of who signed in is the
9
- * access token itself: an RS-family bearer JWT (RS512 on dev-93's JWKS, measured) signed by the IdP bearer key (jwks_uri in discovery) with sub, email,
10
- * roles, amr, token_type.
11
- *
12
- * TRUST RULE: a claim is read ONLY from a token whose signature, issuer, expiry and token_type were verified here.
13
- * Never base64-decode and believe. Only RSA signatures are accepted (the RS and PS families; the JWKS key's own declared alg is binding), so a token
14
- * re-signed with HS256 using the public key as the secret, or alg none, is refused. The `aud` of an IdP bearer is the IdP's public constant, not this client, so it is not a
15
- * binding; the binding to THIS client is the authorization code + PKCE verifier + exact redirect_uri that Core checks.
16
- *
17
- * The redirect_uri sent here MUST be byte-identical at the authorize step and the token step (Core compares it
18
- * Ordinal at the token step) and equal to the registered {site_url}/api/auth/callback (Core compares it exactly,
19
- * case-insensitively, at the authorize step). Both come from ONE value: the `redirectURI` of the provider config.
20
- */
21
- import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTVerifyGetKey } from 'jose';
22
-
23
- export interface IdpTokenGrant {
24
- accessToken: string;
25
- refreshToken?: string;
26
- /** epoch ms */
27
- accessTokenExpires?: number;
28
- tokenType?: string;
29
- /** The `user` object of the IdP response, passed through untouched (shape not contractually fixed). */
30
- user?: Record<string, unknown>;
31
- }
32
-
33
- export interface IdpVerifiedClaims {
34
- sub: string;
35
- email: string;
36
- name: string;
37
- userId?: string;
38
- roles: string[];
39
- /**
40
- * true ONLY when the verified token asserts email_verified === true (or "true"). Never assumed: better-auth links by email
41
- * from what getUserInfo returns, so an unasserted value must not let an IdP email attach to an existing local account.
42
- */
43
- emailVerified: boolean;
44
- /** true only when the VERIFIED access token's amr contains "mfa" (Core's HasSecondFactor rule). */
45
- mfaVerified: boolean;
46
- }
47
-
48
- export interface IdpOAuthHookDeps {
49
- fetch?: typeof fetch;
50
- /** Key resolver for a jwks_uri. Default: jose createRemoteJWKSet. Tests inject a local key set. */
51
- getKeySet?: (jwksUri: string) => JWTVerifyGetKey;
52
- now?: () => number;
53
- }
54
-
55
- export interface IdpOAuthHookOptions {
56
- discoveryUrl: string;
57
- clientId: string;
58
- redirectURI: string;
59
- /** Override for the expected `iss`. Default: the discovery document's `issuer`. */
60
- issuer?: string;
61
- /**
62
- * Extra origins the discovery document may point token_endpoint / jwks_uri at. By default both MUST share the origin of
63
- * `discoveryUrl`, so a document cannot send the code exchange or the key fetch to another host. Set this only for a
64
- * split-host deployment (internal discovery URL, public endpoints). Comma-separated env: IDP_OAUTH_TRUSTED_ORIGINS.
65
- */
66
- trustedOrigins?: string[];
67
- onTokens?: (grant: IdpTokenGrant) => void;
68
- onClaims?: (claims: IdpVerifiedClaims) => void;
69
- deps?: IdpOAuthHookDeps;
70
- }
71
-
72
- const DISCOVERY_TTL_MS = 10 * 60 * 1000;
73
- const HTTP_TIMEOUT_MS = 10_000;
74
-
75
- /** The URI sent at authorize AND token time. Explicit env wins; else the registered {app}/api/auth/callback form. */
76
- export function resolveIdpRedirectUri(appBaseUrl: string, env: NodeJS.ProcessEnv = process.env): string {
77
- const explicit = env.IDP_OAUTH_REDIRECT_URI?.trim();
78
- if (explicit) return explicit;
79
- return appBaseUrl.replace(/\/+$/, '') + '/api/auth/callback';
80
- }
81
-
82
- /** amr arrives as an array, a JSON-array string, or a delimited string (Core's own reader accepts all three). */
83
- export function amrIncludesMfa(amr: unknown): boolean {
84
- let values: unknown[] = [];
85
- if (Array.isArray(amr)) values = amr;
86
- else if (typeof amr === 'string') {
87
- const t = amr.trim();
88
- if (t.startsWith('[')) {
89
- try {
90
- const parsed = JSON.parse(t);
91
- if (Array.isArray(parsed)) values = parsed;
92
- } catch {
93
- values = [];
94
- }
95
- } else {
96
- values = t.split(/[\s,]+/);
97
- }
98
- }
99
- return values.some((v) => typeof v === 'string' && v.trim().toLowerCase() === 'mfa');
100
- }
101
-
102
- /** Max skew accepted on nbf/exp/iat when verifying the IdP access token. */
103
- export const CLOCK_TOLERANCE_SECONDS = 30;
104
-
105
- /**
106
- * Address shape, a POSITIVE character rule (PAY-2337, Scout 74285): the local part may contain only [A-Za-z0-9] and . _ % + - ' ; the domain only
107
- * [A-Za-z0-9-] labels joined by dots; one @, <= 254 total, local <= 64, no leading/trailing/double dot in the local part, a dotted domain.
108
- * Everything else is NOT an address, and in particular a MASK: Core masks data.user.email on purpose (MaskedEmailJsonConverter), today with '*',
109
- * tomorrow maybe a bullet, an ellipsis or an 'x' run; a blacklist of one glyph would pass the next mask. Not a deliverability check.
110
- */
111
- export function isAddressShaped(v: unknown): v is string {
112
- if (typeof v !== 'string' || v.length === 0 || v.length > 254) return false;
113
- if (!/^[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+$/.test(v)) return false;
114
- const [local, domain] = v.split('@');
115
- if (local.length > 64 || local.startsWith('.') || local.endsWith('.') || local.includes('..')) return false;
116
- return /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(domain) && !domain.startsWith('-') && !domain.endsWith('-');
117
- }
118
-
119
- /**
120
- * The account address (BAPert 74193 / 74196, 4.7.8). Core's code-flow access token has NO email claim; its sub and ws-identity name claim are
121
- * both user.UserName, which is a LOGIN, not the mailbox: it may be address-shaped and still differ from the account's Email (4.7.7 vetoed on that
122
- * and refused every such sign-in, NightHawk 74189). Sources: the token RESPONSE's data.user.email (token endpoint, TLS) and/or the signed token's
123
- * own email claim; if both are present they must be equal. sub / the name claim are NEITHER a source NOR a veto. Not trimmed. Returns '' on any doubt.
124
- */
125
- export function resolveEmail(payload: Record<string, unknown>, responseEmail?: unknown): string {
126
- // NOT trimmed: padding or a trailing newline is a malformed address (refused by the shape check), never silently repaired.
127
- const raw = (v: unknown) => (typeof v === 'string' ? v : '');
128
- const sources = [raw(payload.email), raw(responseEmail)].filter((v) => v.trim() !== '');
129
- if (sources.length === 0 || !sources.every(isAddressShaped)) return '';
130
- const chosen = sources[0];
131
- const same = (a: string) => a.toLowerCase() === chosen.toLowerCase();
132
- if (!sources.every(same)) return '';
133
- return chosen;
134
- }
135
-
136
- function rolesOf(claim: unknown): string[] {
137
- if (Array.isArray(claim)) return claim.filter((r): r is string => typeof r === 'string' && r.length > 0);
138
- if (typeof claim === 'string' && claim.trim()) {
139
- const t = claim.trim();
140
- if (t.startsWith('[')) {
141
- try {
142
- const parsed = JSON.parse(t);
143
- if (Array.isArray(parsed)) return parsed.filter((r): r is string => typeof r === 'string' && r.length > 0);
144
- } catch {
145
- return [];
146
- }
147
- }
148
- return t.split(/[\s,]+/).filter(Boolean);
149
- }
150
- return [];
151
- }
152
-
153
- export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
154
- const fetchFn = opts.deps?.fetch ?? fetch;
155
- const now = opts.deps?.now ?? (() => Date.now());
156
- const getKeySet = opts.deps?.getKeySet ?? ((uri: string) => createRemoteJWKSet(new URL(uri)));
157
-
158
- let discovery: { doc: Record<string, unknown>; at: number } | null = null;
159
- const keySets = new Map<string, JWTVerifyGetKey>();
160
- /** access token -> data.user.email / user_name from the token response, consumed by getUserInfo for THAT token (bounded; never keyed by anything shared). */
161
- const responseUsers = new Map<string, { email: string; userName: string }>();
162
-
163
- async function loadDiscovery(): Promise<Record<string, unknown>> {
164
- if (discovery && now() - discovery.at < DISCOVERY_TTL_MS) return discovery.doc;
165
- const res = await fetchFn(opts.discoveryUrl, {
166
- headers: { Accept: 'application/json' },
167
- redirect: 'error',
168
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
169
- });
170
- if (!res.ok) throw new Error(`IdP discovery returned ${res.status}`);
171
- const doc = (await res.json()) as Record<string, unknown>;
172
- if (typeof doc?.token_endpoint !== 'string' || typeof doc?.jwks_uri !== 'string' || typeof doc?.issuer !== 'string') {
173
- throw new Error('IdP discovery document is missing token_endpoint, jwks_uri or issuer');
174
- }
175
- const allowed = new Set([new URL(opts.discoveryUrl).origin, ...(opts.trustedOrigins ?? []).map((o) => o.replace(/\/+$/, ''))]);
176
- for (const field of ['token_endpoint', 'jwks_uri'] as const) {
177
- const origin = new URL(doc[field] as string).origin;
178
- if (!allowed.has(origin)) throw new Error(`IdP discovery ${field} points at an untrusted origin (${origin})`);
179
- }
180
- discovery = { doc, at: now() }; // only a good document is cached
181
- return doc;
182
- }
183
-
184
- /** better-auth `getToken`: JSON body, envelope unwrapped, no data.access_token => refused. */
185
- async function getToken(data: { code: string; codeVerifier?: string }) {
186
- const doc = await loadDiscovery();
187
- if (!data.codeVerifier) throw new Error('IdP token exchange refused: no PKCE code_verifier');
188
- const res = await fetchFn(doc.token_endpoint as string, {
189
- method: 'POST',
190
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
191
- redirect: 'error',
192
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
193
- body: JSON.stringify({
194
- grant_type: 'authorization_code',
195
- code: data.code,
196
- code_verifier: data.codeVerifier,
197
- // Deliberately NOT data.redirectURI: that is better-auth's own computed callback, not the registered URI.
198
- redirect_uri: opts.redirectURI,
199
- client_id: opts.clientId,
200
- }),
201
- });
202
- let body: any;
203
- try {
204
- body = await res.json();
205
- } catch {
206
- throw new Error(`IdP token response (${res.status}) was not JSON`);
207
- }
208
- if (!res.ok) {
209
- const code = body?.error?.code ?? body?.error ?? body?.code ?? 'unknown';
210
- throw new Error(`IdP token exchange refused (${res.status}, ${typeof code === 'string' ? code : 'unknown'})`);
211
- }
212
- const d = body?.data;
213
- if (!d || typeof d.access_token !== 'string' || !d.access_token) {
214
- throw new Error('IdP token response has no data.access_token (envelope not as expected)');
215
- }
216
- const expiresIn = typeof d.expires_in === 'number' && d.expires_in > 0 ? d.expires_in : undefined;
217
- const expires = expiresIn ? now() + expiresIn * 1000 : undefined;
218
- if (d.user && typeof d.user === 'object' && typeof d.user.email === 'string') {
219
- if (responseUsers.size >= 64) responseUsers.delete(responseUsers.keys().next().value as string);
220
- responseUsers.set(d.access_token, { email: d.user.email, userName: typeof d.user.user_name === 'string' ? d.user.user_name : '' });
221
- }
222
- opts.onTokens?.({
223
- accessToken: d.access_token,
224
- refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
225
- accessTokenExpires: expires,
226
- tokenType: typeof d.token_type === 'string' ? d.token_type : undefined,
227
- user: d.user && typeof d.user === 'object' ? d.user : undefined,
228
- });
229
- return {
230
- tokenType: typeof d.token_type === 'string' ? d.token_type : 'Bearer',
231
- accessToken: d.access_token,
232
- refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
233
- accessTokenExpiresAt: expires ? new Date(expires) : undefined,
234
- scopes: [] as string[],
235
- };
236
- }
237
-
238
- /** better-auth `getUserInfo`: the user comes from the VERIFIED access token. Returns null (never a guess) on any doubt. */
239
- async function getUserInfo(tokens: { accessToken?: string }) {
240
- try {
241
- if (!tokens?.accessToken) return null;
242
- const doc = await loadDiscovery();
243
- // A token without a kid is refused: no "first key" fallback. (A kid that is not in the key set is refused by jose.)
244
- const kid = decodeProtectedHeader(tokens.accessToken).kid;
245
- if (typeof kid !== 'string' || !kid) throw new Error('token header has no kid');
246
- const jwksUri = doc.jwks_uri as string;
247
- let keys = keySets.get(jwksUri);
248
- if (!keys) {
249
- keys = getKeySet(jwksUri);
250
- keySets.set(jwksUri, keys);
251
- }
252
- const { payload } = await jwtVerify(tokens.accessToken, keys, {
253
- // RSA signatures ONLY (RS256/384/512, PS256/384/512; BAPert 74117): never HS* (a token "signed" with the public key as an HMAC
254
- // secret), never none, never ES. 4.7.4/4.7.5 pinned RS256 from a source read; the live IdP's JWKS (dev-93, kid 46) declares alg RS512
255
- // and every real access token was refused (NightHawk 74114). Which RS alg is binding is the JWKS KEY's own declared
256
- // `alg`: jose only offers a JWK to a token whose header alg equals that JWK's `alg`, so a token cannot choose a
257
- // weaker/other algorithm than the published key says. A JWK with no `alg` accepts any listed RSA alg; a symmetric (oct) key is never usable for these algorithms.
258
- algorithms: ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'],
259
- issuer: opts.issuer ?? (doc.issuer as string),
260
- currentDate: new Date(now()),
261
- // The IdP stamps nbf = issue time with no skew and the token is verified in the same second it is minted (NightHawk 74151:
262
- // "nbf claim timestamp check failed"). 30 s, bounded. jose applies it to exp too: a token is accepted up to 30 s after exp.
263
- clockTolerance: CLOCK_TOLERANCE_SECONDS,
264
- });
265
- if (payload.token_type !== 'bearer_token') throw new Error('token_type is not bearer_token');
266
- if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('no sub');
267
- const hasEmailClaim = typeof payload.email === 'string' && payload.email.trim() !== '';
268
- const responseUser = responseUsers.get(tokens.accessToken);
269
- responseUsers.delete(tokens.accessToken);
270
- // The response user object (UNSIGNED body) must be the TOKEN's user (Scout 74198, DotNetPert-Scout 74202): Core fills data.user.user_name and the
271
- // signed sub from the same loaded user in one mint, so WHENEVER a response user exists (even if a signed email claim is also present), user_name MUST be present
272
- // and equal sub (case-insensitive). A different or missing user_name means the address may belong to someone else -> refuse. Does not constrain email == sub.
273
- if (responseUser && responseUser.userName.trim().toLowerCase() !== payload.sub.trim().toLowerCase()) {
274
- throw new Error('token response user is missing or does not match the token subject');
275
- }
276
- // A MASKED / unusable response address is ABSENT (PAY-2337), not "a different address": a valid signed claim still signs in, and with no claim
277
- // there is no source. NEVER repaired or unmasked (no prefix match against sub or the claim). Log names only the fact, no value, no length.
278
- const responseEmailRaw = responseUser?.email ?? '';
279
- const responseEmailUsable = isAddressShaped(responseEmailRaw);
280
- const responseMasked = responseEmailRaw.trim() !== '' && !responseEmailUsable;
281
- if (responseMasked) console.warn('[BETTER_AUTH] payez-idp token response address is masked or unusable; ignored');
282
- const email = resolveEmail(payload as Record<string, unknown>, responseEmailUsable ? responseEmailRaw : undefined);
283
- if (!email) {
284
- throw new Error(responseMasked ? 'response address is masked' : 'no usable email (needs data.user.email or an email claim, address-shaped, equal when both are present)');
285
- }
286
- const name =
287
- (typeof payload.name === 'string' && payload.name.trim()) ||
288
- (typeof payload.full_name === 'string' && payload.full_name.trim()) ||
289
- email.split('@')[0];
290
- const claims: IdpVerifiedClaims = {
291
- sub: payload.sub,
292
- email,
293
- name,
294
- userId: typeof payload.user_id === 'string' || typeof payload.user_id === 'number' ? String(payload.user_id) : undefined,
295
- roles: rolesOf(payload.roles),
296
- mfaVerified: amrIncludesMfa(payload.amr),
297
- // Only the signed token's own email CLAIM can be verified; an address that came from the token response (data.user.email) never asserts it.
298
- emailVerified: hasEmailClaim && (payload.email_verified === true || payload.email_verified === 'true'),
299
- };
300
- opts.onClaims?.(claims);
301
- return { id: claims.sub, email: claims.email, name: claims.name, emailVerified: claims.emailVerified };
302
- } catch (err) {
303
- // Reason only, never the token.
304
- console.warn('[BETTER_AUTH] payez-idp access token refused:', err instanceof Error ? err.message : String(err));
305
- return null;
306
- }
307
- }
308
-
309
- return { getToken, getUserInfo };
310
- }
1
+ /**
2
+ * PAY-2305 (next-mvp 4.7.4): the two hooks that let better-auth's genericOAuth talk to THIS IdP.
3
+ *
4
+ * WHY THEY EXIST (source read, PayEz-Core master d53f192af, BAPert 74014):
5
+ * - POST /api/oauth/token takes a JSON body (snake_case), not better-auth's default form body, and answers with the
6
+ * Core response envelope { success, data: { access_token, refresh_token, token_type, expires_in, user } }.
7
+ * better-auth's default exchange reads top-level fields and would find nothing.
8
+ * - There is NO id_token and NO userinfo endpoint (deliberate, PAY-1585). The only proof of who signed in is the
9
+ * access token itself: an RS-family bearer JWT (RS512 on dev-93's JWKS, measured) signed by the IdP bearer key (jwks_uri in discovery) with sub, email,
10
+ * roles, amr, token_type.
11
+ *
12
+ * TRUST RULE: a claim is read ONLY from a token whose signature, issuer, expiry and token_type were verified here.
13
+ * Never base64-decode and believe. Only RSA signatures are accepted (the RS and PS families; the JWKS key's own declared alg is binding), so a token
14
+ * re-signed with HS256 using the public key as the secret, or alg none, is refused. The `aud` of an IdP bearer is the IdP's public constant, not this client, so it is not a
15
+ * binding; the binding to THIS client is the authorization code + PKCE verifier + exact redirect_uri that Core checks.
16
+ *
17
+ * The redirect_uri sent here MUST be byte-identical at the authorize step and the token step (Core compares it
18
+ * Ordinal at the token step) and equal to the registered {site_url}/api/auth/callback (Core compares it exactly,
19
+ * case-insensitively, at the authorize step). Both come from ONE value: the `redirectURI` of the provider config.
20
+ */
21
+ import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTVerifyGetKey } from 'jose';
22
+
23
+ export interface IdpTokenGrant {
24
+ accessToken: string;
25
+ refreshToken?: string;
26
+ /** epoch ms */
27
+ accessTokenExpires?: number;
28
+ tokenType?: string;
29
+ /** The `user` object of the IdP response, passed through untouched (shape not contractually fixed). */
30
+ user?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface IdpVerifiedClaims {
34
+ sub: string;
35
+ email: string;
36
+ name: string;
37
+ userId?: string;
38
+ roles: string[];
39
+ /**
40
+ * true ONLY when the verified token asserts email_verified === true (or "true"). Never assumed: better-auth links by email
41
+ * from what getUserInfo returns, so an unasserted value must not let an IdP email attach to an existing local account.
42
+ */
43
+ emailVerified: boolean;
44
+ /** true only when the VERIFIED access token's amr contains "mfa" (Core's HasSecondFactor rule). */
45
+ mfaVerified: boolean;
46
+ }
47
+
48
+ export interface IdpOAuthHookDeps {
49
+ fetch?: typeof fetch;
50
+ /** Key resolver for a jwks_uri. Default: jose createRemoteJWKSet. Tests inject a local key set. */
51
+ getKeySet?: (jwksUri: string) => JWTVerifyGetKey;
52
+ now?: () => number;
53
+ }
54
+
55
+ export interface IdpOAuthHookOptions {
56
+ discoveryUrl: string;
57
+ clientId: string;
58
+ redirectURI: string;
59
+ /** Override for the expected `iss`. Default: the discovery document's `issuer`. */
60
+ issuer?: string;
61
+ /**
62
+ * Extra origins the discovery document may point token_endpoint / jwks_uri at. By default both MUST share the origin of
63
+ * `discoveryUrl`, so a document cannot send the code exchange or the key fetch to another host. Set this only for a
64
+ * split-host deployment (internal discovery URL, public endpoints). Comma-separated env: IDP_OAUTH_TRUSTED_ORIGINS.
65
+ */
66
+ trustedOrigins?: string[];
67
+ onTokens?: (grant: IdpTokenGrant) => void;
68
+ onClaims?: (claims: IdpVerifiedClaims) => void;
69
+ deps?: IdpOAuthHookDeps;
70
+ }
71
+
72
+ const DISCOVERY_TTL_MS = 10 * 60 * 1000;
73
+ const HTTP_TIMEOUT_MS = 10_000;
74
+
75
+ /** The URI sent at authorize AND token time. Explicit env wins; else the registered {app}/api/auth/callback form. */
76
+ export function resolveIdpRedirectUri(appBaseUrl: string, env: NodeJS.ProcessEnv = process.env): string {
77
+ const explicit = env.IDP_OAUTH_REDIRECT_URI?.trim();
78
+ if (explicit) return explicit;
79
+ return appBaseUrl.replace(/\/+$/, '') + '/api/auth/callback';
80
+ }
81
+
82
+ /** amr arrives as an array, a JSON-array string, or a delimited string (Core's own reader accepts all three). */
83
+ export function amrIncludesMfa(amr: unknown): boolean {
84
+ let values: unknown[] = [];
85
+ if (Array.isArray(amr)) values = amr;
86
+ else if (typeof amr === 'string') {
87
+ const t = amr.trim();
88
+ if (t.startsWith('[')) {
89
+ try {
90
+ const parsed = JSON.parse(t);
91
+ if (Array.isArray(parsed)) values = parsed;
92
+ } catch {
93
+ values = [];
94
+ }
95
+ } else {
96
+ values = t.split(/[\s,]+/);
97
+ }
98
+ }
99
+ return values.some((v) => typeof v === 'string' && v.trim().toLowerCase() === 'mfa');
100
+ }
101
+
102
+ /** Max skew accepted on nbf/exp/iat when verifying the IdP access token. */
103
+ export const CLOCK_TOLERANCE_SECONDS = 30;
104
+
105
+ /**
106
+ * Address shape, a POSITIVE character rule (PAY-2337, Scout 74285): the local part may contain only [A-Za-z0-9] and . _ % + - ' ; the domain only
107
+ * [A-Za-z0-9-] labels joined by dots; one @, <= 254 total, local <= 64, no leading/trailing/double dot in the local part, a dotted domain.
108
+ * Everything else is NOT an address, and in particular a MASK: Core masks data.user.email on purpose (MaskedEmailJsonConverter), today with '*',
109
+ * tomorrow maybe a bullet, an ellipsis or an 'x' run; a blacklist of one glyph would pass the next mask. Not a deliverability check.
110
+ */
111
+ export function isAddressShaped(v: unknown): v is string {
112
+ if (typeof v !== 'string' || v.length === 0 || v.length > 254) return false;
113
+ if (!/^[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+$/.test(v)) return false;
114
+ const [local, domain] = v.split('@');
115
+ if (local.length > 64 || local.startsWith('.') || local.endsWith('.') || local.includes('..')) return false;
116
+ return /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(domain) && !domain.startsWith('-') && !domain.endsWith('-');
117
+ }
118
+
119
+ /**
120
+ * The account address (BAPert 74193 / 74196, 4.7.8). Core's code-flow access token has NO email claim; its sub and ws-identity name claim are
121
+ * both user.UserName, which is a LOGIN, not the mailbox: it may be address-shaped and still differ from the account's Email (4.7.7 vetoed on that
122
+ * and refused every such sign-in, NightHawk 74189). Sources: the token RESPONSE's data.user.email (token endpoint, TLS) and/or the signed token's
123
+ * own email claim; if both are present they must be equal. sub / the name claim are NEITHER a source NOR a veto. Not trimmed. Returns '' on any doubt.
124
+ */
125
+ export function resolveEmail(payload: Record<string, unknown>, responseEmail?: unknown): string {
126
+ // NOT trimmed: padding or a trailing newline is a malformed address (refused by the shape check), never silently repaired.
127
+ const raw = (v: unknown) => (typeof v === 'string' ? v : '');
128
+ const sources = [raw(payload.email), raw(responseEmail)].filter((v) => v.trim() !== '');
129
+ if (sources.length === 0 || !sources.every(isAddressShaped)) return '';
130
+ const chosen = sources[0];
131
+ const same = (a: string) => a.toLowerCase() === chosen.toLowerCase();
132
+ if (!sources.every(same)) return '';
133
+ return chosen;
134
+ }
135
+
136
+ function rolesOf(claim: unknown): string[] {
137
+ if (Array.isArray(claim)) return claim.filter((r): r is string => typeof r === 'string' && r.length > 0);
138
+ if (typeof claim === 'string' && claim.trim()) {
139
+ const t = claim.trim();
140
+ if (t.startsWith('[')) {
141
+ try {
142
+ const parsed = JSON.parse(t);
143
+ if (Array.isArray(parsed)) return parsed.filter((r): r is string => typeof r === 'string' && r.length > 0);
144
+ } catch {
145
+ return [];
146
+ }
147
+ }
148
+ return t.split(/[\s,]+/).filter(Boolean);
149
+ }
150
+ return [];
151
+ }
152
+
153
+ export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
154
+ const fetchFn = opts.deps?.fetch ?? fetch;
155
+ const now = opts.deps?.now ?? (() => Date.now());
156
+ const getKeySet = opts.deps?.getKeySet ?? ((uri: string) => createRemoteJWKSet(new URL(uri)));
157
+
158
+ let discovery: { doc: Record<string, unknown>; at: number } | null = null;
159
+ const keySets = new Map<string, JWTVerifyGetKey>();
160
+ /** access token -> data.user.email / user_name from the token response, consumed by getUserInfo for THAT token (bounded; never keyed by anything shared). */
161
+ const responseUsers = new Map<string, { email: string; userName: string }>();
162
+
163
+ async function loadDiscovery(): Promise<Record<string, unknown>> {
164
+ if (discovery && now() - discovery.at < DISCOVERY_TTL_MS) return discovery.doc;
165
+ const res = await fetchFn(opts.discoveryUrl, {
166
+ headers: { Accept: 'application/json' },
167
+ redirect: 'error',
168
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
169
+ });
170
+ if (!res.ok) throw new Error(`IdP discovery returned ${res.status}`);
171
+ const doc = (await res.json()) as Record<string, unknown>;
172
+ if (typeof doc?.token_endpoint !== 'string' || typeof doc?.jwks_uri !== 'string' || typeof doc?.issuer !== 'string') {
173
+ throw new Error('IdP discovery document is missing token_endpoint, jwks_uri or issuer');
174
+ }
175
+ const allowed = new Set([new URL(opts.discoveryUrl).origin, ...(opts.trustedOrigins ?? []).map((o) => o.replace(/\/+$/, ''))]);
176
+ for (const field of ['token_endpoint', 'jwks_uri'] as const) {
177
+ const origin = new URL(doc[field] as string).origin;
178
+ if (!allowed.has(origin)) throw new Error(`IdP discovery ${field} points at an untrusted origin (${origin})`);
179
+ }
180
+ discovery = { doc, at: now() }; // only a good document is cached
181
+ return doc;
182
+ }
183
+
184
+ /** better-auth `getToken`: JSON body, envelope unwrapped, no data.access_token => refused. */
185
+ async function getToken(data: { code: string; codeVerifier?: string }) {
186
+ const doc = await loadDiscovery();
187
+ if (!data.codeVerifier) throw new Error('IdP token exchange refused: no PKCE code_verifier');
188
+ const res = await fetchFn(doc.token_endpoint as string, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
191
+ redirect: 'error',
192
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
193
+ body: JSON.stringify({
194
+ grant_type: 'authorization_code',
195
+ code: data.code,
196
+ code_verifier: data.codeVerifier,
197
+ // Deliberately NOT data.redirectURI: that is better-auth's own computed callback, not the registered URI.
198
+ redirect_uri: opts.redirectURI,
199
+ client_id: opts.clientId,
200
+ }),
201
+ });
202
+ let body: any;
203
+ try {
204
+ body = await res.json();
205
+ } catch {
206
+ throw new Error(`IdP token response (${res.status}) was not JSON`);
207
+ }
208
+ if (!res.ok) {
209
+ const code = body?.error?.code ?? body?.error ?? body?.code ?? 'unknown';
210
+ throw new Error(`IdP token exchange refused (${res.status}, ${typeof code === 'string' ? code : 'unknown'})`);
211
+ }
212
+ const d = body?.data;
213
+ if (!d || typeof d.access_token !== 'string' || !d.access_token) {
214
+ throw new Error('IdP token response has no data.access_token (envelope not as expected)');
215
+ }
216
+ const expiresIn = typeof d.expires_in === 'number' && d.expires_in > 0 ? d.expires_in : undefined;
217
+ const expires = expiresIn ? now() + expiresIn * 1000 : undefined;
218
+ if (d.user && typeof d.user === 'object' && typeof d.user.email === 'string') {
219
+ if (responseUsers.size >= 64) responseUsers.delete(responseUsers.keys().next().value as string);
220
+ responseUsers.set(d.access_token, { email: d.user.email, userName: typeof d.user.user_name === 'string' ? d.user.user_name : '' });
221
+ }
222
+ opts.onTokens?.({
223
+ accessToken: d.access_token,
224
+ refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
225
+ accessTokenExpires: expires,
226
+ tokenType: typeof d.token_type === 'string' ? d.token_type : undefined,
227
+ user: d.user && typeof d.user === 'object' ? d.user : undefined,
228
+ });
229
+ return {
230
+ tokenType: typeof d.token_type === 'string' ? d.token_type : 'Bearer',
231
+ accessToken: d.access_token,
232
+ refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
233
+ accessTokenExpiresAt: expires ? new Date(expires) : undefined,
234
+ scopes: [] as string[],
235
+ };
236
+ }
237
+
238
+ /** better-auth `getUserInfo`: the user comes from the VERIFIED access token. Returns null (never a guess) on any doubt. */
239
+ async function getUserInfo(tokens: { accessToken?: string }) {
240
+ try {
241
+ if (!tokens?.accessToken) return null;
242
+ const doc = await loadDiscovery();
243
+ // A token without a kid is refused: no "first key" fallback. (A kid that is not in the key set is refused by jose.)
244
+ const kid = decodeProtectedHeader(tokens.accessToken).kid;
245
+ if (typeof kid !== 'string' || !kid) throw new Error('token header has no kid');
246
+ const jwksUri = doc.jwks_uri as string;
247
+ let keys = keySets.get(jwksUri);
248
+ if (!keys) {
249
+ keys = getKeySet(jwksUri);
250
+ keySets.set(jwksUri, keys);
251
+ }
252
+ const { payload } = await jwtVerify(tokens.accessToken, keys, {
253
+ // RSA signatures ONLY (RS256/384/512, PS256/384/512; BAPert 74117): never HS* (a token "signed" with the public key as an HMAC
254
+ // secret), never none, never ES. 4.7.4/4.7.5 pinned RS256 from a source read; the live IdP's JWKS (dev-93, kid 46) declares alg RS512
255
+ // and every real access token was refused (NightHawk 74114). Which RS alg is binding is the JWKS KEY's own declared
256
+ // `alg`: jose only offers a JWK to a token whose header alg equals that JWK's `alg`, so a token cannot choose a
257
+ // weaker/other algorithm than the published key says. A JWK with no `alg` accepts any listed RSA alg; a symmetric (oct) key is never usable for these algorithms.
258
+ algorithms: ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'],
259
+ issuer: opts.issuer ?? (doc.issuer as string),
260
+ currentDate: new Date(now()),
261
+ // The IdP stamps nbf = issue time with no skew and the token is verified in the same second it is minted (NightHawk 74151:
262
+ // "nbf claim timestamp check failed"). 30 s, bounded. jose applies it to exp too: a token is accepted up to 30 s after exp.
263
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
264
+ });
265
+ if (payload.token_type !== 'bearer_token') throw new Error('token_type is not bearer_token');
266
+ if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('no sub');
267
+ const hasEmailClaim = typeof payload.email === 'string' && payload.email.trim() !== '';
268
+ const responseUser = responseUsers.get(tokens.accessToken);
269
+ responseUsers.delete(tokens.accessToken);
270
+ // The response user object (UNSIGNED body) must be the TOKEN's user (Scout 74198, DotNetPert-Scout 74202): Core fills data.user.user_name and the
271
+ // signed sub from the same loaded user in one mint, so WHENEVER a response user exists (even if a signed email claim is also present), user_name MUST be present
272
+ // and equal sub (case-insensitive). A different or missing user_name means the address may belong to someone else -> refuse. Does not constrain email == sub.
273
+ if (responseUser && responseUser.userName.trim().toLowerCase() !== payload.sub.trim().toLowerCase()) {
274
+ throw new Error('token response user is missing or does not match the token subject');
275
+ }
276
+ // A MASKED / unusable response address is ABSENT (PAY-2337), not "a different address": a valid signed claim still signs in, and with no claim
277
+ // there is no source. NEVER repaired or unmasked (no prefix match against sub or the claim). Log names only the fact, no value, no length.
278
+ const responseEmailRaw = responseUser?.email ?? '';
279
+ const responseEmailUsable = isAddressShaped(responseEmailRaw);
280
+ const responseMasked = responseEmailRaw.trim() !== '' && !responseEmailUsable;
281
+ if (responseMasked) console.warn('[BETTER_AUTH] payez-idp token response address is masked or unusable; ignored');
282
+ const email = resolveEmail(payload as Record<string, unknown>, responseEmailUsable ? responseEmailRaw : undefined);
283
+ if (!email) {
284
+ throw new Error(responseMasked ? 'response address is masked' : 'no usable email (needs data.user.email or an email claim, address-shaped, equal when both are present)');
285
+ }
286
+ const name =
287
+ (typeof payload.name === 'string' && payload.name.trim()) ||
288
+ (typeof payload.full_name === 'string' && payload.full_name.trim()) ||
289
+ email.split('@')[0];
290
+ const claims: IdpVerifiedClaims = {
291
+ sub: payload.sub,
292
+ email,
293
+ name,
294
+ userId: typeof payload.user_id === 'string' || typeof payload.user_id === 'number' ? String(payload.user_id) : undefined,
295
+ roles: rolesOf(payload.roles),
296
+ mfaVerified: amrIncludesMfa(payload.amr),
297
+ // Only the signed token's own email CLAIM can be verified; an address that came from the token response (data.user.email) never asserts it.
298
+ emailVerified: hasEmailClaim && (payload.email_verified === true || payload.email_verified === 'true'),
299
+ };
300
+ opts.onClaims?.(claims);
301
+ return { id: claims.sub, email: claims.email, name: claims.name, emailVerified: claims.emailVerified };
302
+ } catch (err) {
303
+ // Reason only, never the token.
304
+ console.warn('[BETTER_AUTH] payez-idp access token refused:', err instanceof Error ? err.message : String(err));
305
+ return null;
306
+ }
307
+ }
308
+
309
+ return { getToken, getUserInfo };
310
+ }