@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.
- package/POST-INSTALL.md +408 -408
- package/README.md +859 -859
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/session-store.js +21 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/middleware/create-middleware.js +6 -5
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/dist/vibe/sessions.js +28 -3
- package/package.json +2 -2
- package/scripts/check-publish-eol.mjs +33 -15
- package/src/api/auth-handler.ts +550 -550
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/change-password.ts +170 -170
- package/src/api-handlers/account/masked-info.ts +46 -46
- package/src/api-handlers/account/profile.ts +99 -99
- package/src/api-handlers/account/recovery/forward.ts +117 -117
- package/src/api-handlers/account/recovery/initiate.ts +59 -59
- package/src/api-handlers/account/recovery/send-code.ts +21 -21
- package/src/api-handlers/account/recovery/verify-code.ts +20 -20
- package/src/api-handlers/account/reset-password.ts +87 -87
- package/src/api-handlers/account/send-code.ts +70 -70
- package/src/api-handlers/account/update-phone.ts +84 -84
- package/src/api-handlers/account/validate-password.ts +139 -139
- package/src/api-handlers/account/verify-email.ts +134 -134
- package/src/api-handlers/account/verify-sms.ts +134 -134
- package/src/api-handlers/admin/analytics.ts +452 -452
- package/src/api-handlers/admin/audit.ts +232 -232
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +259 -259
- package/src/api-handlers/admin/sessions.ts +327 -327
- package/src/api-handlers/admin/site-logs.ts +388 -388
- package/src/api-handlers/admin/stats.ts +260 -260
- package/src/api-handlers/admin/users.ts +251 -251
- package/src/api-handlers/admin/vibe-data.ts +333 -333
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
- package/src/api-handlers/auth/credential-login.ts +948 -948
- package/src/api-handlers/auth/jwks.ts +31 -31
- package/src/api-handlers/auth/login.ts +252 -252
- package/src/api-handlers/auth/passkey-signin.ts +116 -116
- package/src/api-handlers/auth/refresh.ts +829 -829
- package/src/api-handlers/auth/signout.ts +201 -201
- package/src/api-handlers/auth/status.ts +23 -23
- package/src/api-handlers/auth/update-session.ts +144 -144
- package/src/api-handlers/auth/validate.ts +45 -45
- package/src/api-handlers/auth/verify-code.ts +116 -116
- package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
- package/src/api-handlers/mfa/backup-codes.ts +42 -42
- package/src/api-handlers/mfa/methods.ts +47 -47
- package/src/api-handlers/mfa/totp-confirm.ts +43 -43
- package/src/api-handlers/mfa/totp-enroll.ts +43 -43
- package/src/api-handlers/mfa/totp-verify.ts +172 -172
- package/src/api-handlers/oauth/resume.ts +182 -182
- package/src/api-handlers/onboarding/send-email-code.ts +18 -18
- package/src/api-handlers/onboarding/set-password.ts +21 -21
- package/src/api-handlers/onboarding/verify-code.ts +20 -20
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
- package/src/api-handlers/passkeys/list.ts +32 -32
- package/src/api-handlers/passkeys/manage.ts +55 -55
- package/src/api-handlers/passkeys/register-options.ts +43 -43
- package/src/api-handlers/passkeys/register-verify.ts +42 -42
- package/src/api-handlers/session/refresh-viability.ts +56 -56
- package/src/api-handlers/session/viability.ts +238 -238
- package/src/api-handlers/test/force-expire.ts +167 -167
- package/src/auth/auth-decision.ts +292 -292
- package/src/auth/better-auth.ts +1688 -1688
- package/src/auth/idp-oauth.ts +310 -310
- package/src/auth/password-reset-bridge.ts +98 -98
- package/src/auth/route-config.ts +236 -236
- package/src/auth/safe-callback.ts +199 -199
- package/src/auth/session-cookie-clear.ts +30 -30
- package/src/auth/session-reset-link.ts +159 -159
- package/src/auth/session-response-sanitizer.ts +151 -151
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/auth/utils/idp-client.ts +364 -364
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +192 -192
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +277 -277
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +508 -508
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +421 -421
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/CredentialSignInForm.tsx +240 -240
- package/src/components/auth/FederatedAuthSection.tsx +129 -129
- package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
- package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
- package/src/components/auth/PasswordResetBridge.tsx +30 -30
- package/src/components/auth/TraditionalAuthSection.tsx +103 -103
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/RequestedStep.tsx +30 -30
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +118 -118
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +55 -55
- package/src/config/logger.ts +62 -62
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +109 -109
- package/src/hooks/useAvailableProviders.ts +143 -143
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/useProfile.ts +82 -82
- package/src/hooks/usePublicAuthSettings.ts +171 -171
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +337 -337
- package/src/index.ts +64 -64
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +228 -228
- package/src/lib/authorize-resume.ts +172 -172
- package/src/lib/bearer-fetch.ts +31 -31
- package/src/lib/client-key.ts +88 -88
- package/src/lib/code-step-copy.ts +83 -83
- package/src/lib/credential-sign-in-client.ts +101 -101
- package/src/lib/demo-mode.ts +18 -18
- package/src/lib/device-id.ts +67 -67
- package/src/lib/ensure-fresh-access-token.ts +509 -509
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-client-config.ts +1005 -1005
- package/src/lib/idp-fetch.ts +169 -169
- package/src/lib/idp-url-guard.ts +37 -37
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/lock-window.ts +49 -49
- package/src/lib/login-error-message.ts +41 -41
- package/src/lib/never-in-response.ts +64 -64
- package/src/lib/nextauth-secret.ts +168 -168
- package/src/lib/onboarding-proxy.ts +57 -57
- package/src/lib/password-rules.ts +50 -50
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +105 -105
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/roles.ts +177 -177
- package/src/lib/safe-error-response.ts +149 -149
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-cookie-shape.ts +185 -185
- package/src/lib/session-store.ts +1092 -1092
- package/src/lib/session.ts +34 -34
- package/src/lib/single-flight.ts +64 -64
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/startup-init.ts +276 -276
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +546 -546
- package/src/lib/token-log-id.ts +69 -69
- package/src/lib/twofa-rejection.ts +91 -91
- package/src/lib/types/api-responses.ts +336 -336
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +1139 -1138
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +331 -331
- package/src/pages/admin-login/page.tsx +111 -111
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +338 -338
- package/src/pages/onboarding/index.ts +11 -11
- package/src/pages/onboarding/page.tsx +212 -212
- package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +267 -267
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
- package/src/pages/security/index.ts +12 -12
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
- package/src/pages/test-env/JwtInspectPage.tsx +449 -449
- package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
- package/src/pages/test-env/TestEnvPage.tsx +130 -130
- package/src/pages/test-env/guarded.tsx +81 -81
- package/src/pages/test-env/index.ts +77 -77
- package/src/pages/test-env/redact-credentials.ts +96 -96
- package/src/pages/verify-code/page.tsx +746 -746
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/credential-login-email-code.ts +6 -6
- package/src/routes/auth/credential-login.ts +6 -6
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/logout.ts +111 -111
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +77 -77
- package/src/routes/auth/session-reset.ts +37 -37
- package/src/routes/auth/session.ts +261 -261
- package/src/routes/auth/settings.ts +181 -181
- package/src/routes/auth/viability.ts +519 -519
- package/src/routes/index.ts +17 -17
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/onboarding/index.ts +15 -15
- package/src/routes/onboarding/send-email-code.ts +2 -2
- package/src/routes/onboarding/set-password.ts +2 -2
- package/src/routes/onboarding/verify-code.ts +2 -2
- package/src/routes/onboarding/verify-welcome-email.ts +2 -2
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth-guard.ts +203 -203
- package/src/server/auth.ts +486 -486
- package/src/server/decode-session.ts +309 -309
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/verified-session-token.ts +103 -103
- package/src/server/with-auth.ts +93 -93
- package/src/services/signalrActivityService.ts +258 -258
- package/src/stores/authStore.ts +1900 -1900
- package/src/templates/instrumentation.ts +60 -60
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +48 -48
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +401 -401
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +877 -877
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +209 -209
- package/src/vibe/generic.ts +339 -339
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +133 -133
- package/src/vibe/sessions.ts +925 -899
- 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
|
package/src/lib/bearer-fetch.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/client-key.ts
CHANGED
|
@@ -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
|
+
}
|