@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,175 +1,175 @@
1
- /**
2
- * PAY-2302 cut 2: moved VERBATIM (logic unchanged) from VibeID-Customer-Portal src/lib/auth/authorize-resume.ts.
3
- * PAY-1500 (portal half of PAY-1479): resume the IdP OAuth authorize request after 2FA.
4
- *
5
- * The IdP sends an OAuth principal who still owes 2FA to the portal's /account-auth/verify-code with
6
- * ?next_step=enroll_mfa|mfa_verify&callbackUrl=<absolute IdP authorize URL>&return_url=<same>
7
- * (OAuthController.Authorize; hotfix-2 66ca7a8eb routes BOTH cases to verify-code, /account/security is gone). safeCallbackPath rightly refuses that absolute URL,
8
- * so after 2FA the user used to land on the default page and sign-in never finished.
9
- *
10
- * This is NOT a relaxation of safeCallbackPath. The browser is sent to the portal's own
11
- * /api/oauth/resume, which checks return_url SERVER-SIDE against IDP_URL (origin + the authorize path)
12
- * and only then redirects. Anything else is refused. IDP_URL never reaches the client.
13
- */
14
- export const AUTHORIZE_RESUME_ROUTE = '/api/oauth/resume';
15
- const AUTHORIZE_PATH = '/api/oauth/authorize';
16
-
17
- /** True only for `<IDP_URL origin>/api/oauth/authorize` (path case-insensitive, as ASP.NET routes). */
18
- export function isIdpAuthorizeUrl(candidate: string | null | undefined, idpBaseUrl: string): boolean {
19
- if (typeof candidate !== 'string' || candidate === '') return false;
20
- try {
21
- const u = new URL(candidate);
22
- const idp = new URL(idpBaseUrl);
23
- return (u.protocol === 'https:' || u.protocol === 'http:') && u.origin === idp.origin && u.pathname.toLowerCase() === AUTHORIZE_PATH;
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- /**
30
- * Server side: the redirect target, REBUILT from trusted parts - IDP_URL's origin and the authorize path
31
- * are ours; only the query string comes from the request. Null unless isIdpAuthorizeUrl holds.
32
- */
33
- export function idpAuthorizeRedirect(candidate: string | null | undefined, idpBaseUrl: string): string | null {
34
- if (!isIdpAuthorizeUrl(candidate, idpBaseUrl)) return null;
35
- const query = new URL(candidate as string).search;
36
- return `${new URL(idpBaseUrl).origin}/api/OAuth/authorize${query}`;
37
- }
38
-
39
- /**
40
- * Client side: the SAME-ORIGIN portal path that resumes authorize, or null when the IdP did not ask for one.
41
- * Callers still pass the result through safeCallbackPath before navigating.
42
- *
43
- * WHY THE ASTERISK IS RE-ENCODED (PAY-1547, measured by NextPert 59939, completeness measured here).
44
- * One consumer of this value is `authClient.signIn.social({ callbackURL })`, which POSTs to better-auth
45
- * and is validated by its own originCheckMiddleware BEFORE anything redirects. For a relative path that
46
- * check is decided entirely by one regex (better-auth 1.5.6, auth/trusted-origins.mjs:16 - the
47
- * `allowRelativePaths` branch, and api/middlewares/origin-check.mjs:51 passes
48
- * `allowRelativePaths: label !== 'origin'`, so it IS that branch for a callbackURL):
49
- *
50
- * /^\/(?!\/|\\|%2f|%5c)[\w\-.\+\/@]*(?:\?[\w\-.\+\/=&%@]*)?$/
51
- *
52
- * A character this helper emits RAW and that charset rejects is a hard 403 INVALID_CALLBACK_URL on the
53
- * Google button - not a silent fallback. Measured exhaustively over printable ASCII by diffing what
54
- * `URLSearchParams` leaves raw against that charset: the raw set is alphanumerics plus `* - . _`, and
55
- * *** `*` IS THE ONLY REJECTED MEMBER *** (`%` is permitted, which is why every other special character
56
- * survives - URLSearchParams already percent-encoded it).
57
- *
58
- * `*` is reachable because `state` and `scope` are chosen by the OAuth CLIENT, not by us, so we cannot
59
- * decline to emit it. `%2A` is the same character to every parser: re-reading this query string returns
60
- * the byte-identical `return_url` (asserted in test/authorize-resume.test.cjs), so this is an encoding
61
- * change and NOT a value change. It is applied here, at the ONE computation, rather than at the sinks -
62
- * the two `window.location.href` sinks are browser navigations that touch no library and for which this
63
- * is a behavioural no-op, so there is no reason to make them each carry it.
64
- */
65
- export function authorizeResumeHref(returnUrl: string | null | undefined): string | null {
66
- if (typeof returnUrl !== 'string' || returnUrl === '') return null;
67
- const query = new URLSearchParams({ return_url: returnUrl }).toString().replace(/\*/g, '%2A');
68
- return `${AUTHORIZE_RESUME_ROUTE}?${query}`;
69
- }
70
-
71
- // ───────────────────────── 4.7.5 (PAY-2305): the authorize call is made SERVER-SIDE ─────────────────────────
72
- //
73
- // WHY: Core's /api/oauth/authorize identifies a user ONLY from an `Authorization: Bearer` header (JwtMiddleware reads no cookie,
74
- // and a browser navigation cannot send a header), so the 4.7.0-4.7.4 behaviour - 302 the BROWSER to authorize - could never
75
- // produce a code for anyone. The resume route now calls authorize itself with the signed-in user's IdP access token and relays
76
- // only the verified outcome. These helpers are the pure, testable parts.
77
-
78
- export interface ServerAuthorizeParams {
79
- idpBaseUrl: string;
80
- /** This app's IdP client slug (CLIENT_ID). The request's client_id must equal it. */
81
- clientId: string;
82
- /** The app's REGISTERED redirect_uri, e.g. http://localhost:3000/api/auth/callback. The request's redirect_uri must equal it exactly. */
83
- redirectUri: string;
84
- }
85
-
86
- export interface ServerAuthorizeRequest {
87
- /** Built from constants + validated pieces only. This is what gets fetched. */
88
- url: string;
89
- state: string;
90
- }
91
-
92
- const CHALLENGE_RE = /^[A-Za-z0-9_-]{43,128}$/;
93
- const STATE_RE = /^[A-Za-z0-9._~-]{1,256}$/;
94
- const SCOPE_RE = /^[A-Za-z0-9:._ -]{1,200}$/;
95
-
96
- /**
97
- * Build the authorize URL to call, or null (refuse). Origin, path, response_type, client_id and redirect_uri are OURS; from
98
- * return_url only code_challenge (+ method, S256 only), state and scope are taken, each charset/length validated. A return_url for
99
- * another client or another redirect_uri is refused (no "someone else's client"). Unknown extra parameters are NOT forwarded.
100
- */
101
- export function buildServerAuthorizeRequest(returnUrl: string | null | undefined, p: ServerAuthorizeParams): ServerAuthorizeRequest | null {
102
- if (!isIdpAuthorizeUrl(returnUrl, p.idpBaseUrl) || !p.clientId || !p.redirectUri) return null;
103
- const q = new URL(returnUrl as string).searchParams;
104
- const once = (name: string): string | null => {
105
- const all = q.getAll(name);
106
- return all.length === 1 ? all[0] : all.length === 0 ? null : '\u0000'; // a repeated parameter is ambiguous: refused below
107
- };
108
- const responseType = once('response_type');
109
- if (responseType !== null && responseType !== 'code') return null;
110
- if (once('client_id') !== p.clientId) return null;
111
- if (once('redirect_uri') !== p.redirectUri) return null;
112
- const challenge = once('code_challenge');
113
- const method = once('code_challenge_method');
114
- const state = once('state');
115
- const scope = once('scope');
116
- if (challenge === null || !CHALLENGE_RE.test(challenge)) return null;
117
- if (method !== 'S256') return null;
118
- if (state === null || !STATE_RE.test(state)) return null;
119
- if (scope !== null && !SCOPE_RE.test(scope)) return null;
120
-
121
- const out = new URLSearchParams();
122
- out.set('response_type', 'code');
123
- out.set('client_id', p.clientId);
124
- out.set('redirect_uri', p.redirectUri);
125
- if (scope !== null) out.set('scope', scope);
126
- out.set('state', state);
127
- out.set('code_challenge', challenge);
128
- out.set('code_challenge_method', 'S256');
129
- return { url: `${new URL(p.idpBaseUrl).origin}${AUTHORIZE_PATH}?${out.toString()}`, state };
130
- }
131
-
132
- /**
133
- * The IdP's answer to the server-side authorize call. Returns the RELATIVE path+query to send the browser to, or null (refuse).
134
- * Only a redirect whose Location is EXACTLY the registered redirect_uri (origin + path), with a code and the state we sent and no
135
- * error, qualifies. The relayed value is REBUILT from the validated code and state, never copied from the Location.
136
- */
137
- export function relayRegisteredCallback(status: number, location: string | null | undefined, redirectUri: string, sentState: string): string | null {
138
- if (status < 300 || status > 399 || typeof location !== 'string' || location === '') return null;
139
- let loc: URL;
140
- let reg: URL;
141
- try {
142
- reg = new URL(redirectUri);
143
- loc = new URL(location, reg.origin);
144
- } catch {
145
- return null;
146
- }
147
- if (loc.origin !== reg.origin || loc.pathname !== reg.pathname) return null;
148
- if (loc.searchParams.has('error')) return null;
149
- const codes = loc.searchParams.getAll('code');
150
- const states = loc.searchParams.getAll('state');
151
- if (codes.length !== 1 || !/^[A-Za-z0-9._~+/=-]{8,512}$/.test(codes[0])) return null;
152
- if (states.length !== 1 || states[0] !== sentState) return null;
153
- return `${reg.pathname}?${new URLSearchParams({ code: codes[0], state: sentState }).toString()}`;
154
- }
155
-
156
- /**
157
- * The resume must be a same-site navigation. Sec-Fetch-Site decides when the browser sends it; otherwise Referer must be this
158
- * app's own origin; with neither it is refused (a crafted cross-site link must not make the victim's browser trigger authorize
159
- * with the victim's token).
160
- */
161
- export function isSameSiteResume(headers: { get(name: string): string | null }, appOrigin: string): boolean {
162
- const sfs = headers.get('sec-fetch-site');
163
- // same-origin or none only: a sibling subdomain ('same-site') is not this app.
164
- if (sfs !== null) return sfs === 'same-origin' || sfs === 'none';
165
- const referer = headers.get('referer');
166
- if (!referer) return false;
167
- try {
168
- return new URL(referer).origin === appOrigin;
169
- } catch {
170
- return false;
171
- }
172
- }
1
+ /**
2
+ * PAY-2302 cut 2: moved VERBATIM (logic unchanged) from VibeID-Customer-Portal src/lib/auth/authorize-resume.ts.
3
+ * PAY-1500 (portal half of PAY-1479): resume the IdP OAuth authorize request after 2FA.
4
+ *
5
+ * The IdP sends an OAuth principal who still owes 2FA to the portal's /account-auth/verify-code with
6
+ * ?next_step=enroll_mfa|mfa_verify&callbackUrl=<absolute IdP authorize URL>&return_url=<same>
7
+ * (OAuthController.Authorize; hotfix-2 66ca7a8eb routes BOTH cases to verify-code, /account/security is gone). safeCallbackPath rightly refuses that absolute URL,
8
+ * so after 2FA the user used to land on the default page and sign-in never finished.
9
+ *
10
+ * This is NOT a relaxation of safeCallbackPath. The browser is sent to the portal's own
11
+ * /api/oauth/resume, which checks return_url SERVER-SIDE against IDP_URL (origin + the authorize path)
12
+ * and only then redirects. Anything else is refused. IDP_URL never reaches the client.
13
+ */
14
+ export const AUTHORIZE_RESUME_ROUTE = '/api/oauth/resume';
15
+ const AUTHORIZE_PATH = '/api/oauth/authorize';
16
+
17
+ /** True only for `<IDP_URL origin>/api/oauth/authorize` (path case-insensitive, as ASP.NET routes). */
18
+ export function isIdpAuthorizeUrl(candidate: string | null | undefined, idpBaseUrl: string): boolean {
19
+ if (typeof candidate !== 'string' || candidate === '') return false;
20
+ try {
21
+ const u = new URL(candidate);
22
+ const idp = new URL(idpBaseUrl);
23
+ return (u.protocol === 'https:' || u.protocol === 'http:') && u.origin === idp.origin && u.pathname.toLowerCase() === AUTHORIZE_PATH;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Server side: the redirect target, REBUILT from trusted parts - IDP_URL's origin and the authorize path
31
+ * are ours; only the query string comes from the request. Null unless isIdpAuthorizeUrl holds.
32
+ */
33
+ export function idpAuthorizeRedirect(candidate: string | null | undefined, idpBaseUrl: string): string | null {
34
+ if (!isIdpAuthorizeUrl(candidate, idpBaseUrl)) return null;
35
+ const query = new URL(candidate as string).search;
36
+ return `${new URL(idpBaseUrl).origin}/api/OAuth/authorize${query}`;
37
+ }
38
+
39
+ /**
40
+ * Client side: the SAME-ORIGIN portal path that resumes authorize, or null when the IdP did not ask for one.
41
+ * Callers still pass the result through safeCallbackPath before navigating.
42
+ *
43
+ * WHY THE ASTERISK IS RE-ENCODED (PAY-1547, measured by NextPert 59939, completeness measured here).
44
+ * One consumer of this value is `authClient.signIn.social({ callbackURL })`, which POSTs to better-auth
45
+ * and is validated by its own originCheckMiddleware BEFORE anything redirects. For a relative path that
46
+ * check is decided entirely by one regex (better-auth 1.5.6, auth/trusted-origins.mjs:16 - the
47
+ * `allowRelativePaths` branch, and api/middlewares/origin-check.mjs:51 passes
48
+ * `allowRelativePaths: label !== 'origin'`, so it IS that branch for a callbackURL):
49
+ *
50
+ * /^\/(?!\/|\\|%2f|%5c)[\w\-.\+\/@]*(?:\?[\w\-.\+\/=&%@]*)?$/
51
+ *
52
+ * A character this helper emits RAW and that charset rejects is a hard 403 INVALID_CALLBACK_URL on the
53
+ * Google button - not a silent fallback. Measured exhaustively over printable ASCII by diffing what
54
+ * `URLSearchParams` leaves raw against that charset: the raw set is alphanumerics plus `* - . _`, and
55
+ * *** `*` IS THE ONLY REJECTED MEMBER *** (`%` is permitted, which is why every other special character
56
+ * survives - URLSearchParams already percent-encoded it).
57
+ *
58
+ * `*` is reachable because `state` and `scope` are chosen by the OAuth CLIENT, not by us, so we cannot
59
+ * decline to emit it. `%2A` is the same character to every parser: re-reading this query string returns
60
+ * the byte-identical `return_url` (asserted in test/authorize-resume.test.cjs), so this is an encoding
61
+ * change and NOT a value change. It is applied here, at the ONE computation, rather than at the sinks -
62
+ * the two `window.location.href` sinks are browser navigations that touch no library and for which this
63
+ * is a behavioural no-op, so there is no reason to make them each carry it.
64
+ */
65
+ export function authorizeResumeHref(returnUrl: string | null | undefined): string | null {
66
+ if (typeof returnUrl !== 'string' || returnUrl === '') return null;
67
+ const query = new URLSearchParams({ return_url: returnUrl }).toString().replace(/\*/g, '%2A');
68
+ return `${AUTHORIZE_RESUME_ROUTE}?${query}`;
69
+ }
70
+
71
+ // ───────────────────────── 4.7.5 (PAY-2305): the authorize call is made SERVER-SIDE ─────────────────────────
72
+ //
73
+ // WHY: Core's /api/oauth/authorize identifies a user ONLY from an `Authorization: Bearer` header (JwtMiddleware reads no cookie,
74
+ // and a browser navigation cannot send a header), so the 4.7.0-4.7.4 behaviour - 302 the BROWSER to authorize - could never
75
+ // produce a code for anyone. The resume route now calls authorize itself with the signed-in user's IdP access token and relays
76
+ // only the verified outcome. These helpers are the pure, testable parts.
77
+
78
+ export interface ServerAuthorizeParams {
79
+ idpBaseUrl: string;
80
+ /** This app's IdP client slug (CLIENT_ID). The request's client_id must equal it. */
81
+ clientId: string;
82
+ /** The app's REGISTERED redirect_uri, e.g. http://localhost:3000/api/auth/callback. The request's redirect_uri must equal it exactly. */
83
+ redirectUri: string;
84
+ }
85
+
86
+ export interface ServerAuthorizeRequest {
87
+ /** Built from constants + validated pieces only. This is what gets fetched. */
88
+ url: string;
89
+ state: string;
90
+ }
91
+
92
+ const CHALLENGE_RE = /^[A-Za-z0-9_-]{43,128}$/;
93
+ const STATE_RE = /^[A-Za-z0-9._~-]{1,256}$/;
94
+ const SCOPE_RE = /^[A-Za-z0-9:._ -]{1,200}$/;
95
+
96
+ /**
97
+ * Build the authorize URL to call, or null (refuse). Origin, path, response_type, client_id and redirect_uri are OURS; from
98
+ * return_url only code_challenge (+ method, S256 only), state and scope are taken, each charset/length validated. A return_url for
99
+ * another client or another redirect_uri is refused (no "someone else's client"). Unknown extra parameters are NOT forwarded.
100
+ */
101
+ export function buildServerAuthorizeRequest(returnUrl: string | null | undefined, p: ServerAuthorizeParams): ServerAuthorizeRequest | null {
102
+ if (!isIdpAuthorizeUrl(returnUrl, p.idpBaseUrl) || !p.clientId || !p.redirectUri) return null;
103
+ const q = new URL(returnUrl as string).searchParams;
104
+ const once = (name: string): string | null => {
105
+ const all = q.getAll(name);
106
+ return all.length === 1 ? all[0] : all.length === 0 ? null : '\u0000'; // a repeated parameter is ambiguous: refused below
107
+ };
108
+ const responseType = once('response_type');
109
+ if (responseType !== null && responseType !== 'code') return null;
110
+ if (once('client_id') !== p.clientId) return null;
111
+ if (once('redirect_uri') !== p.redirectUri) return null;
112
+ const challenge = once('code_challenge');
113
+ const method = once('code_challenge_method');
114
+ const state = once('state');
115
+ const scope = once('scope');
116
+ if (challenge === null || !CHALLENGE_RE.test(challenge)) return null;
117
+ if (method !== 'S256') return null;
118
+ if (state === null || !STATE_RE.test(state)) return null;
119
+ if (scope !== null && !SCOPE_RE.test(scope)) return null;
120
+
121
+ const out = new URLSearchParams();
122
+ out.set('response_type', 'code');
123
+ out.set('client_id', p.clientId);
124
+ out.set('redirect_uri', p.redirectUri);
125
+ if (scope !== null) out.set('scope', scope);
126
+ out.set('state', state);
127
+ out.set('code_challenge', challenge);
128
+ out.set('code_challenge_method', 'S256');
129
+ return { url: `${new URL(p.idpBaseUrl).origin}${AUTHORIZE_PATH}?${out.toString()}`, state };
130
+ }
131
+
132
+ /**
133
+ * The IdP's answer to the server-side authorize call. Returns the RELATIVE path+query to send the browser to, or null (refuse).
134
+ * Only a redirect whose Location is EXACTLY the registered redirect_uri (origin + path), with a code and the state we sent and no
135
+ * error, qualifies. The relayed value is REBUILT from the validated code and state, never copied from the Location.
136
+ */
137
+ export function relayRegisteredCallback(status: number, location: string | null | undefined, redirectUri: string, sentState: string): string | null {
138
+ if (status < 300 || status > 399 || typeof location !== 'string' || location === '') return null;
139
+ let loc: URL;
140
+ let reg: URL;
141
+ try {
142
+ reg = new URL(redirectUri);
143
+ loc = new URL(location, reg.origin);
144
+ } catch {
145
+ return null;
146
+ }
147
+ if (loc.origin !== reg.origin || loc.pathname !== reg.pathname) return null;
148
+ if (loc.searchParams.has('error')) return null;
149
+ const codes = loc.searchParams.getAll('code');
150
+ const states = loc.searchParams.getAll('state');
151
+ if (codes.length !== 1 || !/^[A-Za-z0-9._~+/=-]{8,512}$/.test(codes[0])) return null;
152
+ if (states.length !== 1 || states[0] !== sentState) return null;
153
+ return `${reg.pathname}?${new URLSearchParams({ code: codes[0], state: sentState }).toString()}`;
154
+ }
155
+
156
+ /**
157
+ * The resume must be a same-site navigation. Sec-Fetch-Site decides when the browser sends it; otherwise Referer must be this
158
+ * app's own origin; with neither it is refused (a crafted cross-site link must not make the victim's browser trigger authorize
159
+ * with the victim's token).
160
+ */
161
+ export function isSameSiteResume(headers: { get(name: string): string | null }, appOrigin: string): boolean {
162
+ const sfs = headers.get('sec-fetch-site');
163
+ // same-origin or none only: a sibling subdomain ('same-site') is not this app.
164
+ if (sfs !== null) return sfs === 'same-origin' || sfs === 'none';
165
+ const referer = headers.get('referer');
166
+ if (!referer) return false;
167
+ try {
168
+ return new URL(referer).origin === appOrigin;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
173
 
174
174
  /**
175
175
  * Binds the resume to a browser that STARTED this app's OAuth flow: better-auth (storeStateStrategy 'cookie') sets an
@@ -1,31 +1,31 @@
1
- /**
2
- * PAY-1777 item 5 (QAPert-NightHawk, PR 663 G4 thread 4395; coverage widened per BAPert 64034): THE way a server-side call
3
- * that carries a user's (or a provisional) bearer token talks to the identity service.
4
- *
5
- * A bearer must never follow a redirect. Modern fetch drops Authorization on a CROSS-origin hop, but a same-origin hop - or
6
- * an older runtime - does not, and we do not rely on that. So:
7
- * - the request is always sent with redirect: 'manual' (set AFTER the caller's init, so no caller can switch it back);
8
- * - any 3xx, or an opaque redirect, is never followed and never relayed: the caller receives a synthetic
9
- * 502 { success:false, error:{ code:'IDP_UNREACHABLE' } } Response instead, which every existing non-2xx branch at the
10
- * call sites already handles. Every other response is returned untouched.
11
- *
12
- * Moved from VibeID-Customer-Portal (src/lib/idp/bearer-fetch.ts) into the package by PAY-2302; the portal's gate
13
- * (testing/qa/idp-bearer-no-redirect-gate.js) keeps inventorying its own Authorization-bearing fetches.
14
- */
15
- export const REDIRECT_REFUSED_CODE = 'IDP_UNREACHABLE';
16
-
17
- export function isRedirectResponse(res: Response): boolean {
18
- return res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400);
19
- }
20
-
21
- export async function bearerFetch(input: string | URL, init: RequestInit = {}): Promise<Response> {
22
- const res = await fetch(input, { ...init, redirect: 'manual' });
23
- if (!isRedirectResponse(res)) return res;
24
- return new Response(
25
- JSON.stringify({
26
- success: false,
27
- error: { code: REDIRECT_REFUSED_CODE, message: 'The identity service answered with a redirect; the call was not followed' },
28
- }),
29
- { status: 502, headers: { 'Content-Type': 'application/json' } },
30
- );
31
- }
1
+ /**
2
+ * PAY-1777 item 5 (QAPert-NightHawk, PR 663 G4 thread 4395; coverage widened per BAPert 64034): THE way a server-side call
3
+ * that carries a user's (or a provisional) bearer token talks to the identity service.
4
+ *
5
+ * A bearer must never follow a redirect. Modern fetch drops Authorization on a CROSS-origin hop, but a same-origin hop - or
6
+ * an older runtime - does not, and we do not rely on that. So:
7
+ * - the request is always sent with redirect: 'manual' (set AFTER the caller's init, so no caller can switch it back);
8
+ * - any 3xx, or an opaque redirect, is never followed and never relayed: the caller receives a synthetic
9
+ * 502 { success:false, error:{ code:'IDP_UNREACHABLE' } } Response instead, which every existing non-2xx branch at the
10
+ * call sites already handles. Every other response is returned untouched.
11
+ *
12
+ * Moved from VibeID-Customer-Portal (src/lib/idp/bearer-fetch.ts) into the package by PAY-2302; the portal's gate
13
+ * (testing/qa/idp-bearer-no-redirect-gate.js) keeps inventorying its own Authorization-bearing fetches.
14
+ */
15
+ export const REDIRECT_REFUSED_CODE = 'IDP_UNREACHABLE';
16
+
17
+ export function isRedirectResponse(res: Response): boolean {
18
+ return res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400);
19
+ }
20
+
21
+ export async function bearerFetch(input: string | URL, init: RequestInit = {}): Promise<Response> {
22
+ const res = await fetch(input, { ...init, redirect: 'manual' });
23
+ if (!isRedirectResponse(res)) return res;
24
+ return new Response(
25
+ JSON.stringify({
26
+ success: false,
27
+ error: { code: REDIRECT_REFUSED_CODE, message: 'The identity service answered with a redirect; the call was not followed' },
28
+ }),
29
+ { status: 502, headers: { 'Content-Type': 'application/json' } },
30
+ );
31
+ }
@@ -1,88 +1,88 @@
1
- /**
2
- * ONE resolver for "which client is this request for?" — card 268410.
3
- *
4
- * THE ASYMMETRY THIS EXISTS TO SURVIVE (measured at source in the IDP,
5
- * RequirePayEzUnAuthorizedAttribute.cs):
6
- *
7
- * :31-33 int.TryParse succeeds -> Items[PayEzClientId] = numericId; return; NO existence check
8
- * :41-47 slug -> GetClientIdByNameAsync; if null -> NotFoundResult() VALIDATED
9
- * :55 no client id at all -> NotFoundResult()
10
- *
11
- * So a WRONG SLUG 404s loudly. A WRONG NUMERIC is silently accepted and the
12
- * request is answered for whatever client that number happens to be. On a
13
- * multi-tenant identity service that is a WRONG-TENANT ANSWER, not merely a bug.
14
- *
15
- * And a stale numeric is the DOCUMENTED NORMAL CASE, not a hypothetical: numeric
16
- * ids are environment-specific (card 250596 — 79 was dev-93's id for the SAME
17
- * slug, and the merge collapsed 76 into 12), while the slug is stable across
18
- * environments.
19
- *
20
- * WHY THE FALLBACK WAS SILENT: idp-client-config.ts:462 and :669 resolve
21
- * `clientSlug: ... ?? ''`. Empty string is FALSY, so the idiom every call site
22
- * used —
23
- *
24
- * config.clientSlug || String(config.clientId)
25
- *
26
- * — falls through to the numeric arm whenever the IDP config carries no slug,
27
- * with nothing emitted to say it happened. Absence collapsed into a
28
- * usable-looking default: the same class the graft's passkey plugin already
29
- * called out when `|| ''` made an unset env var look like a valid client_id.
30
- *
31
- * WHAT THIS DOES **NOT** DO: it does not flip precedence. Slug still wins, the
32
- * numeric fallback is still taken, and every deployment that works today keeps
33
- * working byte-for-byte. Blanket-flipping these to numeric-first is the
34
- * dangerous-looking-safe change the 268410 ruling explicitly holds.
35
- *
36
- * WHAT IT DOES: it makes the dangerous arm ATTRIBUTABLE. Taking the numeric
37
- * fallback now emits a warning naming the call site, so a wrong-tenant answer
38
- * is diagnosable in seconds instead of being indistinguishable from a correct
39
- * one. That ambiguity is what cost hours on S12.
40
- */
41
-
42
- export interface ClientKeySource {
43
- clientSlug?: string | null;
44
- clientId?: string | number | null;
45
- }
46
-
47
- export interface ResolvedClientKey {
48
- /** The routing key to send as X-Client-Id / client_id, or null if none resolved. */
49
- key: string | null;
50
- /** True when the environment-specific numeric arm was taken. */
51
- usedNumericFallback: boolean;
52
- }
53
-
54
- /**
55
- * Resolve the IDP routing key from a client config.
56
- *
57
- * `site` names the caller (e.g. 'admin/analytics') and appears verbatim in the
58
- * fallback warning — that string is the whole diagnostic value, so pass a real one.
59
- *
60
- * Pure and non-throwing: callers decide whether a null key is fatal (refuse) or
61
- * merely means "omit the header". Both shapes exist in this package and both
62
- * are correct for their path.
63
- */
64
- export function resolveClientKey(
65
- config: ClientKeySource | null | undefined,
66
- site: string
67
- ): ResolvedClientKey {
68
- const slug = typeof config?.clientSlug === 'string' ? config.clientSlug.trim() : '';
69
- if (slug) return { key: slug, usedNumericFallback: false };
70
-
71
- const rawNumeric = config?.clientId;
72
- // Guard String(undefined) === 'undefined' — a 13-char string that is not a
73
- // slug and not a number, so the IDP would 404 it with no hint as to why.
74
- if (rawNumeric === null || rawNumeric === undefined) {
75
- return { key: null, usedNumericFallback: false };
76
- }
77
- const numeric = String(rawNumeric).trim();
78
- if (!numeric) return { key: null, usedNumericFallback: false };
79
-
80
- console.warn(
81
- `[client-key] ${site}: no clientSlug on the resolved IDP config — falling back to the ` +
82
- `NUMERIC client id ${numeric}. Numeric ids are ENVIRONMENT-SPECIFIC and the IDP accepts ` +
83
- `them with NO existence check, so if this number is stale for this environment the ` +
84
- `request is being answered for the WRONG CLIENT, silently. Card 268410.`
85
- );
86
-
87
- return { key: numeric, usedNumericFallback: true };
88
- }
1
+ /**
2
+ * ONE resolver for "which client is this request for?" — card 268410.
3
+ *
4
+ * THE ASYMMETRY THIS EXISTS TO SURVIVE (measured at source in the IDP,
5
+ * RequirePayEzUnAuthorizedAttribute.cs):
6
+ *
7
+ * :31-33 int.TryParse succeeds -> Items[PayEzClientId] = numericId; return; NO existence check
8
+ * :41-47 slug -> GetClientIdByNameAsync; if null -> NotFoundResult() VALIDATED
9
+ * :55 no client id at all -> NotFoundResult()
10
+ *
11
+ * So a WRONG SLUG 404s loudly. A WRONG NUMERIC is silently accepted and the
12
+ * request is answered for whatever client that number happens to be. On a
13
+ * multi-tenant identity service that is a WRONG-TENANT ANSWER, not merely a bug.
14
+ *
15
+ * And a stale numeric is the DOCUMENTED NORMAL CASE, not a hypothetical: numeric
16
+ * ids are environment-specific (card 250596 — 79 was dev-93's id for the SAME
17
+ * slug, and the merge collapsed 76 into 12), while the slug is stable across
18
+ * environments.
19
+ *
20
+ * WHY THE FALLBACK WAS SILENT: idp-client-config.ts:462 and :669 resolve
21
+ * `clientSlug: ... ?? ''`. Empty string is FALSY, so the idiom every call site
22
+ * used —
23
+ *
24
+ * config.clientSlug || String(config.clientId)
25
+ *
26
+ * — falls through to the numeric arm whenever the IDP config carries no slug,
27
+ * with nothing emitted to say it happened. Absence collapsed into a
28
+ * usable-looking default: the same class the graft's passkey plugin already
29
+ * called out when `|| ''` made an unset env var look like a valid client_id.
30
+ *
31
+ * WHAT THIS DOES **NOT** DO: it does not flip precedence. Slug still wins, the
32
+ * numeric fallback is still taken, and every deployment that works today keeps
33
+ * working byte-for-byte. Blanket-flipping these to numeric-first is the
34
+ * dangerous-looking-safe change the 268410 ruling explicitly holds.
35
+ *
36
+ * WHAT IT DOES: it makes the dangerous arm ATTRIBUTABLE. Taking the numeric
37
+ * fallback now emits a warning naming the call site, so a wrong-tenant answer
38
+ * is diagnosable in seconds instead of being indistinguishable from a correct
39
+ * one. That ambiguity is what cost hours on S12.
40
+ */
41
+
42
+ export interface ClientKeySource {
43
+ clientSlug?: string | null;
44
+ clientId?: string | number | null;
45
+ }
46
+
47
+ export interface ResolvedClientKey {
48
+ /** The routing key to send as X-Client-Id / client_id, or null if none resolved. */
49
+ key: string | null;
50
+ /** True when the environment-specific numeric arm was taken. */
51
+ usedNumericFallback: boolean;
52
+ }
53
+
54
+ /**
55
+ * Resolve the IDP routing key from a client config.
56
+ *
57
+ * `site` names the caller (e.g. 'admin/analytics') and appears verbatim in the
58
+ * fallback warning — that string is the whole diagnostic value, so pass a real one.
59
+ *
60
+ * Pure and non-throwing: callers decide whether a null key is fatal (refuse) or
61
+ * merely means "omit the header". Both shapes exist in this package and both
62
+ * are correct for their path.
63
+ */
64
+ export function resolveClientKey(
65
+ config: ClientKeySource | null | undefined,
66
+ site: string
67
+ ): ResolvedClientKey {
68
+ const slug = typeof config?.clientSlug === 'string' ? config.clientSlug.trim() : '';
69
+ if (slug) return { key: slug, usedNumericFallback: false };
70
+
71
+ const rawNumeric = config?.clientId;
72
+ // Guard String(undefined) === 'undefined' — a 13-char string that is not a
73
+ // slug and not a number, so the IDP would 404 it with no hint as to why.
74
+ if (rawNumeric === null || rawNumeric === undefined) {
75
+ return { key: null, usedNumericFallback: false };
76
+ }
77
+ const numeric = String(rawNumeric).trim();
78
+ if (!numeric) return { key: null, usedNumericFallback: false };
79
+
80
+ console.warn(
81
+ `[client-key] ${site}: no clientSlug on the resolved IDP config — falling back to the ` +
82
+ `NUMERIC client id ${numeric}. Numeric ids are ENVIRONMENT-SPECIFIC and the IDP accepts ` +
83
+ `them with NO existence check, so if this number is stale for this environment the ` +
84
+ `request is being answered for the WRONG CLIENT, silently. Card 268410.`
85
+ );
86
+
87
+ return { key: numeric, usedNumericFallback: true };
88
+ }