@payez/next-mvp 4.8.0 → 4.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/components/account/MobileNavDrawer.js +18 -18
  4. package/dist/components/account/UserAvatarMenu.js +1 -1
  5. package/dist/components/admin/AlertSettingsTab.js +40 -40
  6. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  7. package/dist/lib/session-store.js +21 -21
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/middleware/create-middleware.js +6 -5
  10. package/dist/pages/login/page.js +28 -28
  11. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  12. package/dist/vibe/sessions.js +28 -3
  13. package/package.json +2 -2
  14. package/scripts/check-publish-eol.mjs +33 -15
  15. package/src/api/auth-handler.ts +550 -550
  16. package/src/api/index.ts +17 -17
  17. package/src/api-handlers/account/change-password.ts +170 -170
  18. package/src/api-handlers/account/masked-info.ts +46 -46
  19. package/src/api-handlers/account/profile.ts +99 -99
  20. package/src/api-handlers/account/recovery/forward.ts +117 -117
  21. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  22. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  23. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  24. package/src/api-handlers/account/reset-password.ts +87 -87
  25. package/src/api-handlers/account/send-code.ts +70 -70
  26. package/src/api-handlers/account/update-phone.ts +84 -84
  27. package/src/api-handlers/account/validate-password.ts +139 -139
  28. package/src/api-handlers/account/verify-email.ts +134 -134
  29. package/src/api-handlers/account/verify-sms.ts +134 -134
  30. package/src/api-handlers/admin/analytics.ts +452 -452
  31. package/src/api-handlers/admin/audit.ts +232 -232
  32. package/src/api-handlers/admin/index.ts +63 -63
  33. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  34. package/src/api-handlers/admin/sessions.ts +327 -327
  35. package/src/api-handlers/admin/site-logs.ts +388 -388
  36. package/src/api-handlers/admin/stats.ts +260 -260
  37. package/src/api-handlers/admin/users.ts +251 -251
  38. package/src/api-handlers/admin/vibe-data.ts +333 -333
  39. package/src/api-handlers/anon/preferences.ts +123 -123
  40. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  41. package/src/api-handlers/auth/credential-login.ts +948 -948
  42. package/src/api-handlers/auth/jwks.ts +31 -31
  43. package/src/api-handlers/auth/login.ts +252 -252
  44. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  45. package/src/api-handlers/auth/refresh.ts +829 -829
  46. package/src/api-handlers/auth/signout.ts +201 -201
  47. package/src/api-handlers/auth/status.ts +23 -23
  48. package/src/api-handlers/auth/update-session.ts +144 -144
  49. package/src/api-handlers/auth/validate.ts +45 -45
  50. package/src/api-handlers/auth/verify-code.ts +116 -116
  51. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  52. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  53. package/src/api-handlers/mfa/methods.ts +47 -47
  54. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  55. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  56. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  57. package/src/api-handlers/oauth/resume.ts +182 -182
  58. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  59. package/src/api-handlers/onboarding/set-password.ts +21 -21
  60. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  61. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  62. package/src/api-handlers/passkeys/list.ts +32 -32
  63. package/src/api-handlers/passkeys/manage.ts +55 -55
  64. package/src/api-handlers/passkeys/register-options.ts +43 -43
  65. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  66. package/src/api-handlers/session/refresh-viability.ts +56 -56
  67. package/src/api-handlers/session/viability.ts +238 -238
  68. package/src/api-handlers/test/force-expire.ts +167 -167
  69. package/src/auth/auth-decision.ts +292 -292
  70. package/src/auth/better-auth.ts +1688 -1688
  71. package/src/auth/idp-oauth.ts +310 -310
  72. package/src/auth/password-reset-bridge.ts +98 -98
  73. package/src/auth/route-config.ts +236 -236
  74. package/src/auth/safe-callback.ts +199 -199
  75. package/src/auth/session-cookie-clear.ts +30 -30
  76. package/src/auth/session-reset-link.ts +159 -159
  77. package/src/auth/session-response-sanitizer.ts +151 -151
  78. package/src/auth/types/auth-types.ts +555 -555
  79. package/src/auth/types/index.ts +7 -7
  80. package/src/auth/unauthenticated-routes.ts +2 -2
  81. package/src/auth/utils/idp-client.ts +364 -364
  82. package/src/auth/utils/index.ts +6 -6
  83. package/src/auth/utils/token-utils.ts +243 -243
  84. package/src/client/AuthContext.tsx +192 -192
  85. package/src/client/better-auth-client.ts +70 -70
  86. package/src/client/fetch-with-auth.ts +47 -47
  87. package/src/client/fetchWithSession.ts +21 -21
  88. package/src/client/index.ts +13 -13
  89. package/src/client/useAnonSession.ts +131 -131
  90. package/src/components/SessionSync.tsx +138 -138
  91. package/src/components/SignalRHealthCheck.tsx +131 -131
  92. package/src/components/account/MobileNavDrawer.tsx +305 -305
  93. package/src/components/account/UserAvatarMenu.tsx +277 -277
  94. package/src/components/account/index.ts +13 -13
  95. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  96. package/src/components/admin/AnalyticsTab.tsx +703 -703
  97. package/src/components/admin/DataBrowserTab.tsx +508 -508
  98. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  99. package/src/components/admin/SessionsTab.tsx +421 -421
  100. package/src/components/admin/StatsTab.tsx +379 -379
  101. package/src/components/admin/VibeAdminContext.tsx +87 -87
  102. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  103. package/src/components/admin/index.ts +59 -59
  104. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  105. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  106. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  107. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  108. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  109. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  110. package/src/components/recovery/CompleteStep.tsx +36 -36
  111. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  112. package/src/components/recovery/RequestedStep.tsx +30 -30
  113. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  114. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  115. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  116. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  117. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  118. package/src/components/ui/BetaBadge.tsx +58 -58
  119. package/src/components/ui/Footer.tsx +93 -93
  120. package/src/config/env.ts +55 -55
  121. package/src/config/logger.ts +62 -62
  122. package/src/config/logging-config.ts +82 -82
  123. package/src/config/unauthenticated-routes.ts +19 -19
  124. package/src/config/vibe-log-transport.ts +264 -264
  125. package/src/edge/internal-api-url.ts +65 -65
  126. package/src/edge/middleware.ts +42 -42
  127. package/src/hooks/useAuth.ts +117 -117
  128. package/src/hooks/useAuthSettings.ts +109 -109
  129. package/src/hooks/useAvailableProviders.ts +143 -143
  130. package/src/hooks/usePasswordValidation.ts +127 -127
  131. package/src/hooks/useProfile.ts +82 -82
  132. package/src/hooks/usePublicAuthSettings.ts +171 -171
  133. package/src/hooks/useSessionExpiration.ts +101 -101
  134. package/src/hooks/useViabilitySession.ts +337 -337
  135. package/src/index.ts +64 -64
  136. package/src/lib/anon-session.ts +213 -213
  137. package/src/lib/api-handler.ts +643 -643
  138. package/src/lib/app-slug.ts +228 -228
  139. package/src/lib/authorize-resume.ts +172 -172
  140. package/src/lib/bearer-fetch.ts +31 -31
  141. package/src/lib/client-key.ts +88 -88
  142. package/src/lib/code-step-copy.ts +83 -83
  143. package/src/lib/credential-sign-in-client.ts +101 -101
  144. package/src/lib/demo-mode.ts +18 -18
  145. package/src/lib/device-id.ts +67 -67
  146. package/src/lib/ensure-fresh-access-token.ts +509 -509
  147. package/src/lib/geolocation.ts +265 -265
  148. package/src/lib/idp-client-config.ts +1005 -1005
  149. package/src/lib/idp-fetch.ts +169 -169
  150. package/src/lib/idp-url-guard.ts +37 -37
  151. package/src/lib/internal-api.ts +171 -171
  152. package/src/lib/jwt-decode-client.ts +45 -45
  153. package/src/lib/jwt-decode.ts +82 -82
  154. package/src/lib/lock-window.ts +49 -49
  155. package/src/lib/login-error-message.ts +41 -41
  156. package/src/lib/never-in-response.ts +64 -64
  157. package/src/lib/nextauth-secret.ts +168 -168
  158. package/src/lib/onboarding-proxy.ts +57 -57
  159. package/src/lib/password-rules.ts +50 -50
  160. package/src/lib/rate-limit-service.ts +9 -9
  161. package/src/lib/redis.ts +105 -105
  162. package/src/lib/refresh-token-validator.ts +64 -64
  163. package/src/lib/roles.ts +177 -177
  164. package/src/lib/safe-error-response.ts +149 -149
  165. package/src/lib/secret-validation.ts +8 -8
  166. package/src/lib/session-cookie-shape.ts +185 -185
  167. package/src/lib/session-store.ts +1092 -1092
  168. package/src/lib/session.ts +34 -34
  169. package/src/lib/single-flight.ts +64 -64
  170. package/src/lib/site-logger.ts +245 -245
  171. package/src/lib/standardized-client-api.ts +901 -901
  172. package/src/lib/startup-init.ts +276 -276
  173. package/src/lib/test-aware-get-token.ts +41 -41
  174. package/src/lib/token-expiry.ts +40 -40
  175. package/src/lib/token-lifecycle.ts +546 -546
  176. package/src/lib/token-log-id.ts +69 -69
  177. package/src/lib/twofa-rejection.ts +91 -91
  178. package/src/lib/types/api-responses.ts +336 -336
  179. package/src/lib/user-agent-parser.ts +252 -252
  180. package/src/logging/api/admin-analytics.ts +51 -51
  181. package/src/logging/api/audit-log.ts +53 -53
  182. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  183. package/src/logging/components/AuditLogViewer.tsx +125 -125
  184. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  185. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  186. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  187. package/src/logging/hooks/useAuditLog.ts +24 -24
  188. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  189. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  190. package/src/logging/index.ts +18 -18
  191. package/src/logging/types/analytics.ts +81 -81
  192. package/src/logging/types/audit.ts +31 -31
  193. package/src/logging/types/index.ts +3 -3
  194. package/src/middleware/create-middleware.ts +1139 -1138
  195. package/src/middleware/rbac-check.ts +279 -279
  196. package/src/middleware/twofa-presets.ts +224 -224
  197. package/src/models/DecodedAccessToken.ts +16 -16
  198. package/src/models/SessionModel.ts +331 -331
  199. package/src/pages/admin-login/page.tsx +111 -111
  200. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  201. package/src/pages/admin-page-permissions/index.ts +7 -7
  202. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  203. package/src/pages/admin-roles/index.ts +9 -9
  204. package/src/pages/admin-roles/modals.tsx +469 -469
  205. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  206. package/src/pages/client-admin/index.ts +33 -33
  207. package/src/pages/coming-soon/page.tsx +83 -83
  208. package/src/pages/login/page.tsx +338 -338
  209. package/src/pages/onboarding/index.ts +11 -11
  210. package/src/pages/onboarding/page.tsx +212 -212
  211. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  212. package/src/pages/profile/index.ts +9 -9
  213. package/src/pages/profile/page.tsx +166 -166
  214. package/src/pages/recovery/page.tsx +267 -267
  215. package/src/pages/roles/MyRolesPage.tsx +211 -211
  216. package/src/pages/roles/components.tsx +294 -294
  217. package/src/pages/roles/index.ts +17 -17
  218. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  219. package/src/pages/security/index.ts +12 -12
  220. package/src/pages/security/page.tsx +507 -507
  221. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  222. package/src/pages/settings/index.ts +9 -9
  223. package/src/pages/settings/page.tsx +47 -47
  224. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  225. package/src/pages/showcase/index.ts +13 -13
  226. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  227. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  228. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  229. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  230. package/src/pages/test-env/guarded.tsx +81 -81
  231. package/src/pages/test-env/index.ts +77 -77
  232. package/src/pages/test-env/redact-credentials.ts +96 -96
  233. package/src/pages/verify-code/page.tsx +746 -746
  234. package/src/routes/account/index.ts +31 -31
  235. package/src/routes/account/masked-info.ts +36 -36
  236. package/src/routes/account/send-code.ts +39 -39
  237. package/src/routes/account/update-phone.ts +13 -13
  238. package/src/routes/account/verify-email.ts +40 -40
  239. package/src/routes/account/verify-sms.ts +40 -40
  240. package/src/routes/auth/credential-login-email-code.ts +6 -6
  241. package/src/routes/auth/credential-login.ts +6 -6
  242. package/src/routes/auth/index.ts +22 -22
  243. package/src/routes/auth/logout.ts +111 -111
  244. package/src/routes/auth/nextauth.ts +45 -45
  245. package/src/routes/auth/passkey-signin.ts +13 -13
  246. package/src/routes/auth/refresh.ts +77 -77
  247. package/src/routes/auth/session-reset.ts +37 -37
  248. package/src/routes/auth/session.ts +261 -261
  249. package/src/routes/auth/settings.ts +181 -181
  250. package/src/routes/auth/viability.ts +519 -519
  251. package/src/routes/index.ts +17 -17
  252. package/src/routes/mfa/backup-code-verify.ts +1 -1
  253. package/src/routes/mfa/backup-codes.ts +10 -10
  254. package/src/routes/mfa/methods.ts +22 -22
  255. package/src/routes/mfa/totp-confirm.ts +10 -10
  256. package/src/routes/mfa/totp-enroll.ts +10 -10
  257. package/src/routes/mfa/totp-verify.ts +14 -14
  258. package/src/routes/onboarding/index.ts +15 -15
  259. package/src/routes/onboarding/send-email-code.ts +2 -2
  260. package/src/routes/onboarding/set-password.ts +2 -2
  261. package/src/routes/onboarding/verify-code.ts +2 -2
  262. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  263. package/src/routes/passkeys/list.ts +10 -10
  264. package/src/routes/passkeys/manage.ts +23 -23
  265. package/src/routes/passkeys/register-options.ts +10 -10
  266. package/src/routes/passkeys/register-verify.ts +10 -10
  267. package/src/routes/session/index.ts +7 -7
  268. package/src/routes/session/refresh-viability.ts +17 -17
  269. package/src/server/auth-guard.ts +203 -203
  270. package/src/server/auth.ts +486 -486
  271. package/src/server/decode-session.ts +309 -309
  272. package/src/server/slim-middleware.ts +117 -117
  273. package/src/server/verified-session-token.ts +103 -103
  274. package/src/server/with-auth.ts +93 -93
  275. package/src/services/signalrActivityService.ts +258 -258
  276. package/src/stores/authStore.ts +1900 -1900
  277. package/src/templates/instrumentation.ts +60 -60
  278. package/src/theme/ThemeProvider.tsx +39 -39
  279. package/src/theme/default.ts +33 -33
  280. package/src/theme/index.ts +31 -31
  281. package/src/theme/types.ts +69 -69
  282. package/src/theme/useTheme.ts +57 -57
  283. package/src/theme/utils.ts +40 -40
  284. package/src/types/api.ts +12 -12
  285. package/src/types/auth.d.ts +23 -23
  286. package/src/types/auth.ts +48 -48
  287. package/src/types/logging.ts +11 -11
  288. package/src/types/recovery.ts +54 -54
  289. package/src/utils/api.ts +401 -401
  290. package/src/utils/circuitBreaker.ts +40 -40
  291. package/src/utils/error-message.ts +108 -108
  292. package/src/utils/layout/reservedSpace.ts +124 -124
  293. package/src/utils/logout.ts +30 -30
  294. package/src/vibe/client.ts +877 -877
  295. package/src/vibe/enterprise-auth.ts +208 -208
  296. package/src/vibe/errors.ts +209 -209
  297. package/src/vibe/generic.ts +339 -339
  298. package/src/vibe/hooks/index.ts +367 -367
  299. package/src/vibe/index.ts +133 -133
  300. package/src/vibe/sessions.ts +925 -899
  301. 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
+ }