@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,520 +1,520 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ready-to-Use Session Viability Route
|
|
3
|
-
*
|
|
4
|
-
* Checks if the current session is viable (valid and not expired).
|
|
5
|
-
* Used by client-side code to determine if a refresh is needed.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* ```typescript
|
|
9
|
-
* // app/api/session/viability/route.ts
|
|
10
|
-
* export { GET } from '@payez/next-mvp/routes/auth/viability';
|
|
11
|
-
* ```
|
|
12
|
-
*
|
|
13
|
-
* @version 2.0.0
|
|
14
|
-
* @since auth-ready-v2
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { revocationFields } from '../../auth/revocation-fields';
|
|
18
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
19
|
-
import { resolveSessionState } from '../../server/auth';
|
|
20
|
-
import {
|
|
21
|
-
getSession as getRedisSession,
|
|
22
|
-
getBetterAuthSession as getBetterAuthRedisSession,
|
|
23
|
-
} from '../../lib/session-store';
|
|
24
|
-
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
25
|
-
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
26
|
-
import { tokenLogId } from '../../lib/token-log-id';
|
|
27
|
-
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Get tenant-wide 2FA requirement from cached client config (from broker handshake)
|
|
31
|
-
*/
|
|
32
|
-
async function getTenantRequiresTwoFactor(): Promise<boolean> {
|
|
33
|
-
try {
|
|
34
|
-
const config = await getIDPClientConfig();
|
|
35
|
-
return config.authSettings?.require2FA ?? true; // Default to true for security
|
|
36
|
-
} catch {
|
|
37
|
-
console.warn('[VIABILITY] Could not get client config, defaulting tenantRequiresTwoFactor to true');
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Which 2FA methods the TENANT permits — cards 141688 + 141741.
|
|
44
|
-
*
|
|
45
|
-
* WHY THIS IS HERE: this endpoint answered "is 2FA required, and is it done?"
|
|
46
|
-
* and said nothing about WITH WHAT. Both the middleware gate and the
|
|
47
|
-
* verify-code page call this route, and NEITHER had any method data — so
|
|
48
|
-
* nothing in the system asked whether the user CAN complete the challenge it
|
|
49
|
-
* is about to demand. A user whose tenant allows only `totp`, with no
|
|
50
|
-
* authenticator enrolled, is sent to a page that offers email and sms.
|
|
51
|
-
*
|
|
52
|
-
* ⚠ THIS IS NOT A COMPLETION SIGNAL AND MUST NEVER BE READ AS ONE.
|
|
53
|
-
* It says what the tenant PERMITS. It says nothing about what the user has
|
|
54
|
-
* enrolled, and nothing about whether they have verified. The invariant
|
|
55
|
-
* (QAPert, acceptance J2/J5) is: METHOD-PRESENCE IS NOT VERIFIED. A user with
|
|
56
|
-
* one method is still challenged and still gated until they actually verify.
|
|
57
|
-
* `userHasCompletedTenantTwoFactorRequirements` remains the only field that
|
|
58
|
-
* answers "are they through".
|
|
59
|
-
*
|
|
60
|
-
* STILL MISSING, deliberately not guessed here: the ENROLLED set. That lives
|
|
61
|
-
* behind /api/mfa/methods and needs a call this route does not currently make.
|
|
62
|
-
* Allowed-only closes the "offered a method the tenant forbids" half; it does
|
|
63
|
-
* NOT close "has nothing enrolled and no way to enrol". Both halves are named
|
|
64
|
-
* in the cards so neither gets lost.
|
|
65
|
-
*
|
|
66
|
-
* NULL MEANS UNKNOWN — IT DOES NOT MEAN "NONE", AND IT DOES NOT MEAN
|
|
67
|
-
* "email and sms". If the client config cannot be read we do not know what the
|
|
68
|
-
* tenant permits, and every available guess is wrong in a way that matters:
|
|
69
|
-
*
|
|
70
|
-
* - guessing ['email','sms'] fails OPEN. On a totp-only tenant that offers
|
|
71
|
-
* the user two methods the tenant forbids — which is defect 141688 itself,
|
|
72
|
-
* re-created by its own fix during a config outage.
|
|
73
|
-
* - guessing [] fails CLOSED but locks the user out of a challenge they may
|
|
74
|
-
* be perfectly able to complete.
|
|
75
|
-
*
|
|
76
|
-
* So we report the truth (we don't know) and let the consumer decide, rather
|
|
77
|
-
* than encoding a guess that reads like a measurement. Same reasoning as the
|
|
78
|
-
* nullable require_2fa fix: "absent" has to be expressible, or callers cannot
|
|
79
|
-
* tell a real value from a default.
|
|
80
|
-
*
|
|
81
|
-
* No local default. 257255 UPDATE: allowed2FAMethods is now OPTIONAL on
|
|
82
|
-
* AuthSettings - the config mapper no longer substitutes ['email','sms'] when
|
|
83
|
-
* the document carries no policy, because that made "no policy" and "exactly
|
|
84
|
-
* these methods" indistinguishable. So absent now correctly reaches this
|
|
85
|
-
* function as undefined and is reported as UNKNOWN (null), which is what this
|
|
86
|
-
* function's own contract already said it wanted. Before the change it would
|
|
87
|
-
* have returned the mapper's fabrication AS IF IT WERE THE TENANT'S VALUE.
|
|
88
|
-
*/
|
|
89
|
-
async function getTenantAllowed2FAMethods(): Promise<string[] | null> {
|
|
90
|
-
try {
|
|
91
|
-
const config = await getIDPClientConfig();
|
|
92
|
-
// `?? null` is the whole point: undefined means the document carried no
|
|
93
|
-
// policy, and this function reports UNKNOWN rather than guessing.
|
|
94
|
-
return config.authSettings.allowed2FAMethods ?? null;
|
|
95
|
-
} catch {
|
|
96
|
-
console.warn('[VIABILITY] Could not get client config — reporting allowed 2FA methods as UNKNOWN (null), not guessing');
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Resolve the client id reported to the caller.
|
|
103
|
-
*
|
|
104
|
-
* Was: `session?.idpClientId || process.env.IDP_CLIENT_ID || ''`. Two defects,
|
|
105
|
-
* both of the "report a confident value where the truth is unknown" family that
|
|
106
|
-
* card 221076 was about:
|
|
107
|
-
*
|
|
108
|
-
* 1. `|| ''` made "we do not know this session's client" indistinguishable from
|
|
109
|
-
* "the client id is blank". Callers cannot tell absence from a known-empty
|
|
110
|
-
* value, so nothing can react to the former. Absence is now `null`, which is
|
|
111
|
-
* representable — and still falsy, so existing `if (!clientId)` guards behave
|
|
112
|
-
* exactly as before.
|
|
113
|
-
*
|
|
114
|
-
* 2. The env fallback SILENTLY substitutes the deployment's default client for a
|
|
115
|
-
* session whose own client is unknown. On a multi-tenant IDP that is a
|
|
116
|
-
* cross-tenant wrong answer, not a default. It is kept for single-tenant
|
|
117
|
-
* deployments that rely on it, but it now says so in the log rather than
|
|
118
|
-
* passing the substitution off as the session's own identity.
|
|
119
|
-
*/
|
|
120
|
-
function resolveClientId(sessionClientId: string | undefined): string | null {
|
|
121
|
-
if (sessionClientId) return sessionClientId;
|
|
122
|
-
|
|
123
|
-
const envClientId = process.env.IDP_CLIENT_ID;
|
|
124
|
-
if (envClientId) {
|
|
125
|
-
console.warn(
|
|
126
|
-
'[VIABILITY_ROUTE] session carries no idpClientId - substituting IDP_CLIENT_ID ' +
|
|
127
|
-
'from the environment. On a multi-tenant deployment this is a DIFFERENT client ' +
|
|
128
|
-
'than the session belongs to.'
|
|
129
|
-
);
|
|
130
|
-
return envClientId;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* GET /api/session/viability - Check if session is viable
|
|
138
|
-
*
|
|
139
|
-
* Returns:
|
|
140
|
-
* - viable: boolean - Whether the session can be used
|
|
141
|
-
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
142
|
-
* - expiresIn: number - Seconds until token expires
|
|
143
|
-
*/
|
|
144
|
-
export async function GET(req: NextRequest) {
|
|
145
|
-
try {
|
|
146
|
-
// PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
|
|
147
|
-
// session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
|
|
148
|
-
// never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
|
|
149
|
-
const sessionState = await resolveSessionState(req);
|
|
150
|
-
|
|
151
|
-
if (sessionState.state === 'unknown') {
|
|
152
|
-
// The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
|
|
153
|
-
// Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
|
|
154
|
-
// path, a non-OK answer -> service_error, which never clears cookies.
|
|
155
|
-
console.warn('[VIABILITY] Session store unavailable - session state unknown');
|
|
156
|
-
return NextResponse.json({
|
|
157
|
-
viable: false,
|
|
158
|
-
needsRefresh: false,
|
|
159
|
-
authenticated: false,
|
|
160
|
-
revocationStatus: 'unknown' as RevocationStatus,
|
|
161
|
-
unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
|
|
162
|
-
error: 'Session store unavailable',
|
|
163
|
-
code: 'STORE_UNAVAILABLE'
|
|
164
|
-
}, { status: 500 });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (sessionState.state === 'missing') {
|
|
168
|
-
// A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
|
|
169
|
-
// stale_session arm (PAY-2216 / 4.5.4) clears it.
|
|
170
|
-
console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
|
|
171
|
-
return NextResponse.json({
|
|
172
|
-
viable: false,
|
|
173
|
-
needsRefresh: false,
|
|
174
|
-
authenticated: false,
|
|
175
|
-
sessionTokenId: tokenLogId(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
|
|
176
|
-
reason: 'Stale session - cookie exists but session not found in Redis'
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (sessionState.state === 'none') {
|
|
181
|
-
return NextResponse.json({
|
|
182
|
-
viable: false,
|
|
183
|
-
needsRefresh: false,
|
|
184
|
-
authenticated: false,
|
|
185
|
-
reason: 'No session found'
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const baSession = sessionState.session;
|
|
190
|
-
|
|
191
|
-
const token = baSession as any;
|
|
192
|
-
const sessionToken = baSession.session?.token;
|
|
193
|
-
|
|
194
|
-
// Try the canonical session-store first; fall back to the Better-Auth-keyed
|
|
195
|
-
// Redis record (`ba:{appSlug}:{token}`) when the canonical lookup misses.
|
|
196
|
-
// Without the fallback, any consumer that wrote a BA session via the OAuth
|
|
197
|
-
// callback path or dev-login and didn't separately populate the canonical
|
|
198
|
-
// store would land here with a "Stale session" verdict, even though the BA
|
|
199
|
-
// stack just resolved the cookie cleanly. Keeps the two viability impls
|
|
200
|
-
// (this route + `api-handlers/session/viability.ts`) in lockstep.
|
|
201
|
-
let session = sessionToken ? await getRedisSession(sessionToken) : null;
|
|
202
|
-
if (sessionToken && !session) {
|
|
203
|
-
session = await getBetterAuthRedisSession(sessionToken);
|
|
204
|
-
if (session) {
|
|
205
|
-
console.log('[VIABILITY] Found session in Better Auth store (fallback)');
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// CRITICAL: Detect stale cookie state (cookie exists but neither store has the session)
|
|
210
|
-
if (sessionToken && !session) {
|
|
211
|
-
console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
|
|
212
|
-
return NextResponse.json({
|
|
213
|
-
viable: false,
|
|
214
|
-
needsRefresh: false,
|
|
215
|
-
authenticated: false,
|
|
216
|
-
sessionTokenId: tokenLogId(sessionToken), // PAY-1481: a correlation id, never the token; middleware only needs presence to clear the stale cookie
|
|
217
|
-
reason: 'Stale session - cookie exists but session not found in Redis'
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
222
|
-
// See the SessionData.nextStep doc. This is the field that tells the gate
|
|
223
|
-
// whether the user CAN complete a challenge; without it both consumers were
|
|
224
|
-
// sending zero-method users to a page they had no way to act on.
|
|
225
|
-
//
|
|
226
|
-
// Declared once, here, above every return: `session` is settled by this
|
|
227
|
-
// point, and all three authenticated responses must carry the SAME value.
|
|
228
|
-
// Deriving it per-branch would let the paths drift apart silently.
|
|
229
|
-
//
|
|
230
|
-
// NO `?? null` HERE, DELIBERATELY — and this is the whole safety argument:
|
|
231
|
-
//
|
|
232
|
-
// undefined = we never captured it. Every session minted BEFORE this
|
|
233
|
-
// change is in that state, and there are live ones in Redis
|
|
234
|
-
// right now. JSON.stringify omits the key, so the consumer
|
|
235
|
-
// sees no field and knows it is uninformed.
|
|
236
|
-
// null = the IDP affirmatively said "nothing outstanding".
|
|
237
|
-
//
|
|
238
|
-
// Collapsing the first into the second with `?? null` would tell the gate
|
|
239
|
-
// "fully authenticated, nothing to do" about every pre-existing session —
|
|
240
|
-
// including a zero-method user who must be sent to enrol. That is a
|
|
241
|
-
// fail-open on the entire existing session population, produced by a
|
|
242
|
-
// default that reads like a measurement. Same mistake as the nullable
|
|
243
|
-
// require_2fa case: absent has to stay expressible.
|
|
244
|
-
const nextStep = session?.nextStep;
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* 266007 — port the sibling's CRITICAL override (api-handlers/session/
|
|
248
|
-
* viability.ts:82-86) into this, the impl consumers actually MOUNT.
|
|
249
|
-
* An OAuth session whose callback set mfaVerified === false because no
|
|
250
|
-
* refresh_token was issued ALWAYS needs 2FA completion, whatever the
|
|
251
|
-
* tenant config says — completing 2FA is how it obtains one.
|
|
252
|
-
*
|
|
253
|
-
* STRICT === false, deliberately: an ABSENT mfaVerified (every session
|
|
254
|
-
* minted before that field existed) is not a signal, and treating it as
|
|
255
|
-
* one would force-rechallenge the entire existing session population.
|
|
256
|
-
*
|
|
257
|
-
* *** R807 (BAPert, card 362550) - WHY THE `=== false` TEST ALONE NO LONGER
|
|
258
|
-
* CARRIES THAT INTENT, AND WHAT RESTORES IT. ***
|
|
259
|
-
*
|
|
260
|
-
* The intent above is right, but by the time a session reaches here the
|
|
261
|
-
* distinction it depends on IS ALREADY GONE: every construction site
|
|
262
|
-
* (session-store, decode-session, token-lifecycle x2, SessionModel) collapses
|
|
263
|
-
* an absent mfaVerified to `false` via `?? false`. So `=== false` had stopped
|
|
264
|
-
* meaning "the callback affirmatively said false" and had come to mean
|
|
265
|
-
* "affirmatively false OR never knew" - i.e. the exact mass re-challenge this
|
|
266
|
-
* comment says it is preventing. The guard read as strict while behaving as
|
|
267
|
-
* permissive, because the collapse happens upstream and out of sight.
|
|
268
|
-
*
|
|
269
|
-
* R807 keeps `mfaVerified` fail-closed at all five sites (removing `?? false`
|
|
270
|
-
* would be an AUTH BYPASS - a session that never proved MFA read as having
|
|
271
|
-
* proved it) and gives THIS question its own signal instead. So the test is
|
|
272
|
-
* now two parts:
|
|
273
|
-
*
|
|
274
|
-
* mfaSignalPresent === true -> the producer really did emit a value, so the
|
|
275
|
-
* `false` below is a MEASUREMENT. Fire.
|
|
276
|
-
* absent / false -> LEGACY session (minted before the signal
|
|
277
|
-
* existed) or a path with no IDP tokens at all.
|
|
278
|
-
* NOT a signal. Do NOT force a re-challenge.
|
|
279
|
-
*
|
|
280
|
-
* That absent-means-do-not-fire read is the migration behaviour, and it is
|
|
281
|
-
* what preserves this comment's original guarantee verbatim through rollout:
|
|
282
|
-
* on the day this ships, EVERY existing session has no signal and none of them
|
|
283
|
-
* is re-challenged.
|
|
284
|
-
*
|
|
285
|
-
* *** AND IT IS SAFE ONLY BECAUSE IT IS CONFINED HERE. *** mfaSignalPresent is
|
|
286
|
-
* permissive by construction - it can only ever SUPPRESS this override. It must
|
|
287
|
-
* never reach a path that grants access; gate those on `mfaVerified`, which is
|
|
288
|
-
* still fail-closed everywhere. R807 is void if this signal escapes into an
|
|
289
|
-
* auth decision.
|
|
290
|
-
*
|
|
291
|
-
* Declared once here, like nextStep above, so the three authenticated
|
|
292
|
-
* responses cannot drift apart. Feeds the legacy `requires2FA` field the
|
|
293
|
-
* middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
|
|
294
|
-
* it names TENANT policy, and this override is not tenant state.
|
|
295
|
-
*/
|
|
296
|
-
const oauthSessionNeedsTwoFactor =
|
|
297
|
-
session?.mfaSignalPresent === true &&
|
|
298
|
-
session?.mfaVerified === false &&
|
|
299
|
-
!(session.idpRefreshToken || (session as any)?.refreshToken);
|
|
300
|
-
|
|
301
|
-
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
302
|
-
/*
|
|
303
|
-
* Resolve the four-state revocation status for this user (BAPert 66320).
|
|
304
|
-
* Read it HERE, above every authenticated return, so all three responses
|
|
305
|
-
* carry the SAME value - the same discipline as nextStep above.
|
|
306
|
-
*
|
|
307
|
-
* The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
|
|
308
|
-
* and always on 'revoked'; 'active' and 'not_configured' stay viable. A
|
|
309
|
-
* consumer that does NOT read revocationStatus is unaffected - absent is not
|
|
310
|
-
* revoked - so shipping this changes nothing for them until they opt in.
|
|
311
|
-
*/
|
|
312
|
-
let revocationStatus: RevocationStatus = 'not_configured';
|
|
313
|
-
if (session) {
|
|
314
|
-
// BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
|
|
315
|
-
// the token's own `sub` claim FIRST (session.userId can be an email).
|
|
316
|
-
const revocationUserId = resolveNumericIdpUserId(session, (session as any).idpAccessToken);
|
|
317
|
-
if (revocationUserId > 0) {
|
|
318
|
-
try {
|
|
319
|
-
revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
|
|
320
|
-
} catch {
|
|
321
|
-
// getRevocationStatus does not throw by contract, but never let a
|
|
322
|
-
// revocation probe take down the viability answer: fail closed.
|
|
323
|
-
revocationStatus = 'unknown';
|
|
324
|
-
}
|
|
325
|
-
} else {
|
|
326
|
-
// N1 (NightHawk 66392): a session exists but carries no usable numeric
|
|
327
|
-
// IdP id, so the check could not run -> 'unknown' (could not determine),
|
|
328
|
-
// NOT 'not_configured' (no revocation source).
|
|
329
|
-
// BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
|
|
330
|
-
// id surfaces as a log line, not an unexplained sign-out.
|
|
331
|
-
revocationStatus = warnNonNumericUserId(sessionToken, session.userId);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Check access token expiry
|
|
336
|
-
const now = Math.floor(Date.now() / 1000);
|
|
337
|
-
// 240654 addendum 2: fall back to the Redis session's own expiry.
|
|
338
|
-
//
|
|
339
|
-
// The first two reads come off the Better Auth session OBJECT. A Path-B
|
|
340
|
-
// synthetic session — the shape server/auth.ts builds for a legacy JWS
|
|
341
|
-
// session — carries NEITHER: it is `{session:{token}, user:{...}}` with no
|
|
342
|
-
// accessTokenExpires and no exp. So every legacy session fell into the
|
|
343
|
-
// "no expiry info" branch below and answered needsRefresh:true on EVERY
|
|
344
|
-
// viability read from the moment of login, while the token was in fact
|
|
345
|
-
// fresh (15-minute TTL measured). Benign for the viable/authenticated
|
|
346
|
-
// verdict, but it recommends a refresh constantly, which is pure perf
|
|
347
|
-
// noise and multiplies exposure to the empty-clientId refresh path (223542).
|
|
348
|
-
//
|
|
349
|
-
// lib/session-store puts the real value on the session as
|
|
350
|
-
// idpAccessTokenExpires, in MILLISECONDS (better-auth.ts:784 sets
|
|
351
|
-
// `Date.now() + expires_in * 1000`). The other two sources are in SECONDS.
|
|
352
|
-
// Mixing units here would be a silent disaster, so note that the
|
|
353
|
-
// normalisation below (`> 1e12 ? /1000 : as-is`) already discriminates
|
|
354
|
-
// them by magnitude: a seconds timestamp is ~1.7e9, a milliseconds one
|
|
355
|
-
// ~1.7e12. This fallback is therefore safe in either unit, and that
|
|
356
|
-
// existing normaliser is load-bearing for it -- do not remove it.
|
|
357
|
-
const accessTokenExpires =
|
|
358
|
-
(token as any).accessTokenExpires ||
|
|
359
|
-
token.exp ||
|
|
360
|
-
(session as any)?.idpAccessTokenExpires;
|
|
361
|
-
|
|
362
|
-
if (!accessTokenExpires) {
|
|
363
|
-
// No expiry info, assume viable but recommend refresh
|
|
364
|
-
const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
|
|
365
|
-
// Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
|
|
366
|
-
const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
|
|
367
|
-
|
|
368
|
-
// CRITICAL: Check if MFA has expired (2FA TTL enforcement)
|
|
369
|
-
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
370
|
-
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
371
|
-
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
372
|
-
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
373
|
-
|
|
374
|
-
// User has completed 2FA requirements if: they verified AND it hasn't expired
|
|
375
|
-
const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
|
|
376
|
-
|
|
377
|
-
// userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
|
|
378
|
-
const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
|
|
379
|
-
|
|
380
|
-
return NextResponse.json({
|
|
381
|
-
viable: true,
|
|
382
|
-
needsRefresh: true,
|
|
383
|
-
authenticated: true,
|
|
384
|
-
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
385
|
-
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
386
|
-
// boolean the middleware's existing force-invalid branch already reads.
|
|
387
|
-
revocationStatus,
|
|
388
|
-
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
389
|
-
// Clear names for middleware decision-making
|
|
390
|
-
tenantRequiresTwoFactor,
|
|
391
|
-
userHasCompletedTenantTwoFactorRequirements,
|
|
392
|
-
userStillNeedsTwoFactor,
|
|
393
|
-
tenantAllowed2FAMethods,
|
|
394
|
-
nextStep,
|
|
395
|
-
// Legacy field names for backwards compatibility
|
|
396
|
-
requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
|
|
397
|
-
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
398
|
-
accessTokenExpired: false,
|
|
399
|
-
reason: 'No expiry information'
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Convert to seconds if needed
|
|
404
|
-
const expiryTime = accessTokenExpires > 1000000000000
|
|
405
|
-
? Math.floor(accessTokenExpires / 1000)
|
|
406
|
-
: accessTokenExpires;
|
|
407
|
-
|
|
408
|
-
const expiresIn = expiryTime - now;
|
|
409
|
-
const isExpired = expiresIn <= 0;
|
|
410
|
-
const needsRefresh = expiresIn <= 300; // 5 minutes buffer
|
|
411
|
-
|
|
412
|
-
// Check if we have refresh capability (check normalized field name first)
|
|
413
|
-
const hasRefreshToken = !!(session?.idpRefreshToken || (session as any)?.refreshToken || (token as any).refreshToken);
|
|
414
|
-
|
|
415
|
-
// CLEAR NAMING: Tenant-wide 2FA requirement from client config
|
|
416
|
-
const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
|
|
417
|
-
// Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
|
|
418
|
-
const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
|
|
419
|
-
|
|
420
|
-
// CRITICAL: Check if MFA has expired (2FA TTL enforcement)
|
|
421
|
-
// The session may have mfaVerified=true from days ago, but if mfaExpiresAt
|
|
422
|
-
// has passed, we must treat 2FA as incomplete to force re-verification.
|
|
423
|
-
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
424
|
-
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
425
|
-
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
426
|
-
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
427
|
-
|
|
428
|
-
// DEBUG: Log what we're reading from the session
|
|
429
|
-
console.log('[VIABILITY] Session 2FA state:', {
|
|
430
|
-
sessionToken: tokenLogId(sessionToken),
|
|
431
|
-
'session.mfaVerified': session?.mfaVerified,
|
|
432
|
-
'session.twoFactorComplete': (session as any)?.twoFactorComplete,
|
|
433
|
-
mfaVerifiedInSession,
|
|
434
|
-
mfaExpiresAt,
|
|
435
|
-
mfaExpired,
|
|
436
|
-
hasRefreshToken,
|
|
437
|
-
'session.idpRefreshToken': !!session?.idpRefreshToken,
|
|
438
|
-
'session.refreshToken': !!(session as any)?.refreshToken,
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
// CLEAR NAMING: User has completed 2FA requirements if: they verified AND it hasn't expired
|
|
442
|
-
const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
|
|
443
|
-
|
|
444
|
-
// userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
|
|
445
|
-
const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
|
|
446
|
-
|
|
447
|
-
if (mfaExpired && mfaVerifiedInSession) {
|
|
448
|
-
console.warn('[VIABILITY] MFA expired - forcing 2FA re-verification');
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (isExpired) {
|
|
452
|
-
return NextResponse.json({
|
|
453
|
-
viable: false,
|
|
454
|
-
needsRefresh: hasRefreshToken,
|
|
455
|
-
expiresIn: 0,
|
|
456
|
-
hasRefreshToken,
|
|
457
|
-
authenticated: true,
|
|
458
|
-
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
459
|
-
// Clear names
|
|
460
|
-
tenantRequiresTwoFactor,
|
|
461
|
-
userHasCompletedTenantTwoFactorRequirements,
|
|
462
|
-
userStillNeedsTwoFactor,
|
|
463
|
-
tenantAllowed2FAMethods,
|
|
464
|
-
nextStep,
|
|
465
|
-
// Legacy names for backwards compatibility
|
|
466
|
-
requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
|
|
467
|
-
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
468
|
-
accessTokenExpired: true,
|
|
469
|
-
reason: 'Token expired',
|
|
470
|
-
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
471
|
-
revocationStatus,
|
|
472
|
-
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
473
|
-
// RBAC fields
|
|
474
|
-
roles: session?.roles || [],
|
|
475
|
-
clientId: resolveClientId(session?.idpClientId),
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
return NextResponse.json({
|
|
480
|
-
viable: true,
|
|
481
|
-
needsRefresh,
|
|
482
|
-
expiresIn,
|
|
483
|
-
hasRefreshToken,
|
|
484
|
-
authenticated: true,
|
|
485
|
-
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
486
|
-
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
487
|
-
revocationStatus,
|
|
488
|
-
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
489
|
-
// Clear names
|
|
490
|
-
tenantRequiresTwoFactor,
|
|
491
|
-
userHasCompletedTenantTwoFactorRequirements,
|
|
492
|
-
userStillNeedsTwoFactor,
|
|
493
|
-
tenantAllowed2FAMethods,
|
|
494
|
-
nextStep,
|
|
495
|
-
// Legacy names for backwards compatibility
|
|
496
|
-
requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
|
|
497
|
-
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
498
|
-
accessTokenExpired: false,
|
|
499
|
-
expiresAt: new Date(expiryTime * 1000).toISOString(),
|
|
500
|
-
// RBAC fields
|
|
501
|
-
roles: session?.roles || [],
|
|
502
|
-
clientId: resolveClientId(session?.idpClientId),
|
|
503
|
-
});
|
|
504
|
-
} catch (error) {
|
|
505
|
-
// PAY-1438: pre-auth probe - the exception text stays in the log, keyed by correlationId.
|
|
506
|
-
const correlationId = logExceptionWithCorrelation('[VIABILITY_ROUTE] Error checking session viability:', error);
|
|
507
|
-
return NextResponse.json({
|
|
508
|
-
viable: false,
|
|
509
|
-
needsRefresh: false,
|
|
510
|
-
authenticated: false,
|
|
511
|
-
// PAY-1882 step 4: an exception here means the status could not be read.
|
|
512
|
-
// Report 'unknown' (not a bare 500 with no revocation signal) so the
|
|
513
|
-
// middleware's fail-closed default applies rather than reading it as viable.
|
|
514
|
-
revocationStatus: 'unknown' as RevocationStatus,
|
|
515
|
-
error: 'Failed to check session',
|
|
516
|
-
code: 'INTERNAL_ERROR',
|
|
517
|
-
correlationId
|
|
518
|
-
}, { status: 500 });
|
|
519
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Ready-to-Use Session Viability Route
|
|
3
|
+
*
|
|
4
|
+
* Checks if the current session is viable (valid and not expired).
|
|
5
|
+
* Used by client-side code to determine if a refresh is needed.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // app/api/session/viability/route.ts
|
|
10
|
+
* export { GET } from '@payez/next-mvp/routes/auth/viability';
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* @version 2.0.0
|
|
14
|
+
* @since auth-ready-v2
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { revocationFields } from '../../auth/revocation-fields';
|
|
18
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
19
|
+
import { resolveSessionState } from '../../server/auth';
|
|
20
|
+
import {
|
|
21
|
+
getSession as getRedisSession,
|
|
22
|
+
getBetterAuthSession as getBetterAuthRedisSession,
|
|
23
|
+
} from '../../lib/session-store';
|
|
24
|
+
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
25
|
+
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
26
|
+
import { tokenLogId } from '../../lib/token-log-id';
|
|
27
|
+
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get tenant-wide 2FA requirement from cached client config (from broker handshake)
|
|
31
|
+
*/
|
|
32
|
+
async function getTenantRequiresTwoFactor(): Promise<boolean> {
|
|
33
|
+
try {
|
|
34
|
+
const config = await getIDPClientConfig();
|
|
35
|
+
return config.authSettings?.require2FA ?? true; // Default to true for security
|
|
36
|
+
} catch {
|
|
37
|
+
console.warn('[VIABILITY] Could not get client config, defaulting tenantRequiresTwoFactor to true');
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which 2FA methods the TENANT permits — cards 141688 + 141741.
|
|
44
|
+
*
|
|
45
|
+
* WHY THIS IS HERE: this endpoint answered "is 2FA required, and is it done?"
|
|
46
|
+
* and said nothing about WITH WHAT. Both the middleware gate and the
|
|
47
|
+
* verify-code page call this route, and NEITHER had any method data — so
|
|
48
|
+
* nothing in the system asked whether the user CAN complete the challenge it
|
|
49
|
+
* is about to demand. A user whose tenant allows only `totp`, with no
|
|
50
|
+
* authenticator enrolled, is sent to a page that offers email and sms.
|
|
51
|
+
*
|
|
52
|
+
* ⚠ THIS IS NOT A COMPLETION SIGNAL AND MUST NEVER BE READ AS ONE.
|
|
53
|
+
* It says what the tenant PERMITS. It says nothing about what the user has
|
|
54
|
+
* enrolled, and nothing about whether they have verified. The invariant
|
|
55
|
+
* (QAPert, acceptance J2/J5) is: METHOD-PRESENCE IS NOT VERIFIED. A user with
|
|
56
|
+
* one method is still challenged and still gated until they actually verify.
|
|
57
|
+
* `userHasCompletedTenantTwoFactorRequirements` remains the only field that
|
|
58
|
+
* answers "are they through".
|
|
59
|
+
*
|
|
60
|
+
* STILL MISSING, deliberately not guessed here: the ENROLLED set. That lives
|
|
61
|
+
* behind /api/mfa/methods and needs a call this route does not currently make.
|
|
62
|
+
* Allowed-only closes the "offered a method the tenant forbids" half; it does
|
|
63
|
+
* NOT close "has nothing enrolled and no way to enrol". Both halves are named
|
|
64
|
+
* in the cards so neither gets lost.
|
|
65
|
+
*
|
|
66
|
+
* NULL MEANS UNKNOWN — IT DOES NOT MEAN "NONE", AND IT DOES NOT MEAN
|
|
67
|
+
* "email and sms". If the client config cannot be read we do not know what the
|
|
68
|
+
* tenant permits, and every available guess is wrong in a way that matters:
|
|
69
|
+
*
|
|
70
|
+
* - guessing ['email','sms'] fails OPEN. On a totp-only tenant that offers
|
|
71
|
+
* the user two methods the tenant forbids — which is defect 141688 itself,
|
|
72
|
+
* re-created by its own fix during a config outage.
|
|
73
|
+
* - guessing [] fails CLOSED but locks the user out of a challenge they may
|
|
74
|
+
* be perfectly able to complete.
|
|
75
|
+
*
|
|
76
|
+
* So we report the truth (we don't know) and let the consumer decide, rather
|
|
77
|
+
* than encoding a guess that reads like a measurement. Same reasoning as the
|
|
78
|
+
* nullable require_2fa fix: "absent" has to be expressible, or callers cannot
|
|
79
|
+
* tell a real value from a default.
|
|
80
|
+
*
|
|
81
|
+
* No local default. 257255 UPDATE: allowed2FAMethods is now OPTIONAL on
|
|
82
|
+
* AuthSettings - the config mapper no longer substitutes ['email','sms'] when
|
|
83
|
+
* the document carries no policy, because that made "no policy" and "exactly
|
|
84
|
+
* these methods" indistinguishable. So absent now correctly reaches this
|
|
85
|
+
* function as undefined and is reported as UNKNOWN (null), which is what this
|
|
86
|
+
* function's own contract already said it wanted. Before the change it would
|
|
87
|
+
* have returned the mapper's fabrication AS IF IT WERE THE TENANT'S VALUE.
|
|
88
|
+
*/
|
|
89
|
+
async function getTenantAllowed2FAMethods(): Promise<string[] | null> {
|
|
90
|
+
try {
|
|
91
|
+
const config = await getIDPClientConfig();
|
|
92
|
+
// `?? null` is the whole point: undefined means the document carried no
|
|
93
|
+
// policy, and this function reports UNKNOWN rather than guessing.
|
|
94
|
+
return config.authSettings.allowed2FAMethods ?? null;
|
|
95
|
+
} catch {
|
|
96
|
+
console.warn('[VIABILITY] Could not get client config — reporting allowed 2FA methods as UNKNOWN (null), not guessing');
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the client id reported to the caller.
|
|
103
|
+
*
|
|
104
|
+
* Was: `session?.idpClientId || process.env.IDP_CLIENT_ID || ''`. Two defects,
|
|
105
|
+
* both of the "report a confident value where the truth is unknown" family that
|
|
106
|
+
* card 221076 was about:
|
|
107
|
+
*
|
|
108
|
+
* 1. `|| ''` made "we do not know this session's client" indistinguishable from
|
|
109
|
+
* "the client id is blank". Callers cannot tell absence from a known-empty
|
|
110
|
+
* value, so nothing can react to the former. Absence is now `null`, which is
|
|
111
|
+
* representable — and still falsy, so existing `if (!clientId)` guards behave
|
|
112
|
+
* exactly as before.
|
|
113
|
+
*
|
|
114
|
+
* 2. The env fallback SILENTLY substitutes the deployment's default client for a
|
|
115
|
+
* session whose own client is unknown. On a multi-tenant IDP that is a
|
|
116
|
+
* cross-tenant wrong answer, not a default. It is kept for single-tenant
|
|
117
|
+
* deployments that rely on it, but it now says so in the log rather than
|
|
118
|
+
* passing the substitution off as the session's own identity.
|
|
119
|
+
*/
|
|
120
|
+
function resolveClientId(sessionClientId: string | undefined): string | null {
|
|
121
|
+
if (sessionClientId) return sessionClientId;
|
|
122
|
+
|
|
123
|
+
const envClientId = process.env.IDP_CLIENT_ID;
|
|
124
|
+
if (envClientId) {
|
|
125
|
+
console.warn(
|
|
126
|
+
'[VIABILITY_ROUTE] session carries no idpClientId - substituting IDP_CLIENT_ID ' +
|
|
127
|
+
'from the environment. On a multi-tenant deployment this is a DIFFERENT client ' +
|
|
128
|
+
'than the session belongs to.'
|
|
129
|
+
);
|
|
130
|
+
return envClientId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* GET /api/session/viability - Check if session is viable
|
|
138
|
+
*
|
|
139
|
+
* Returns:
|
|
140
|
+
* - viable: boolean - Whether the session can be used
|
|
141
|
+
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
142
|
+
* - expiresIn: number - Seconds until token expires
|
|
143
|
+
*/
|
|
144
|
+
export async function GET(req: NextRequest) {
|
|
145
|
+
try {
|
|
146
|
+
// PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
|
|
147
|
+
// session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
|
|
148
|
+
// never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
|
|
149
|
+
const sessionState = await resolveSessionState(req);
|
|
150
|
+
|
|
151
|
+
if (sessionState.state === 'unknown') {
|
|
152
|
+
// The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
|
|
153
|
+
// Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
|
|
154
|
+
// path, a non-OK answer -> service_error, which never clears cookies.
|
|
155
|
+
console.warn('[VIABILITY] Session store unavailable - session state unknown');
|
|
156
|
+
return NextResponse.json({
|
|
157
|
+
viable: false,
|
|
158
|
+
needsRefresh: false,
|
|
159
|
+
authenticated: false,
|
|
160
|
+
revocationStatus: 'unknown' as RevocationStatus,
|
|
161
|
+
unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
|
|
162
|
+
error: 'Session store unavailable',
|
|
163
|
+
code: 'STORE_UNAVAILABLE'
|
|
164
|
+
}, { status: 500 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (sessionState.state === 'missing') {
|
|
168
|
+
// A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
|
|
169
|
+
// stale_session arm (PAY-2216 / 4.5.4) clears it.
|
|
170
|
+
console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
|
|
171
|
+
return NextResponse.json({
|
|
172
|
+
viable: false,
|
|
173
|
+
needsRefresh: false,
|
|
174
|
+
authenticated: false,
|
|
175
|
+
sessionTokenId: tokenLogId(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
|
|
176
|
+
reason: 'Stale session - cookie exists but session not found in Redis'
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (sessionState.state === 'none') {
|
|
181
|
+
return NextResponse.json({
|
|
182
|
+
viable: false,
|
|
183
|
+
needsRefresh: false,
|
|
184
|
+
authenticated: false,
|
|
185
|
+
reason: 'No session found'
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const baSession = sessionState.session;
|
|
190
|
+
|
|
191
|
+
const token = baSession as any;
|
|
192
|
+
const sessionToken = baSession.session?.token;
|
|
193
|
+
|
|
194
|
+
// Try the canonical session-store first; fall back to the Better-Auth-keyed
|
|
195
|
+
// Redis record (`ba:{appSlug}:{token}`) when the canonical lookup misses.
|
|
196
|
+
// Without the fallback, any consumer that wrote a BA session via the OAuth
|
|
197
|
+
// callback path or dev-login and didn't separately populate the canonical
|
|
198
|
+
// store would land here with a "Stale session" verdict, even though the BA
|
|
199
|
+
// stack just resolved the cookie cleanly. Keeps the two viability impls
|
|
200
|
+
// (this route + `api-handlers/session/viability.ts`) in lockstep.
|
|
201
|
+
let session = sessionToken ? await getRedisSession(sessionToken) : null;
|
|
202
|
+
if (sessionToken && !session) {
|
|
203
|
+
session = await getBetterAuthRedisSession(sessionToken);
|
|
204
|
+
if (session) {
|
|
205
|
+
console.log('[VIABILITY] Found session in Better Auth store (fallback)');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// CRITICAL: Detect stale cookie state (cookie exists but neither store has the session)
|
|
210
|
+
if (sessionToken && !session) {
|
|
211
|
+
console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
|
|
212
|
+
return NextResponse.json({
|
|
213
|
+
viable: false,
|
|
214
|
+
needsRefresh: false,
|
|
215
|
+
authenticated: false,
|
|
216
|
+
sessionTokenId: tokenLogId(sessionToken), // PAY-1481: a correlation id, never the token; middleware only needs presence to clear the stale cookie
|
|
217
|
+
reason: 'Stale session - cookie exists but session not found in Redis'
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
222
|
+
// See the SessionData.nextStep doc. This is the field that tells the gate
|
|
223
|
+
// whether the user CAN complete a challenge; without it both consumers were
|
|
224
|
+
// sending zero-method users to a page they had no way to act on.
|
|
225
|
+
//
|
|
226
|
+
// Declared once, here, above every return: `session` is settled by this
|
|
227
|
+
// point, and all three authenticated responses must carry the SAME value.
|
|
228
|
+
// Deriving it per-branch would let the paths drift apart silently.
|
|
229
|
+
//
|
|
230
|
+
// NO `?? null` HERE, DELIBERATELY — and this is the whole safety argument:
|
|
231
|
+
//
|
|
232
|
+
// undefined = we never captured it. Every session minted BEFORE this
|
|
233
|
+
// change is in that state, and there are live ones in Redis
|
|
234
|
+
// right now. JSON.stringify omits the key, so the consumer
|
|
235
|
+
// sees no field and knows it is uninformed.
|
|
236
|
+
// null = the IDP affirmatively said "nothing outstanding".
|
|
237
|
+
//
|
|
238
|
+
// Collapsing the first into the second with `?? null` would tell the gate
|
|
239
|
+
// "fully authenticated, nothing to do" about every pre-existing session —
|
|
240
|
+
// including a zero-method user who must be sent to enrol. That is a
|
|
241
|
+
// fail-open on the entire existing session population, produced by a
|
|
242
|
+
// default that reads like a measurement. Same mistake as the nullable
|
|
243
|
+
// require_2fa case: absent has to stay expressible.
|
|
244
|
+
const nextStep = session?.nextStep;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 266007 — port the sibling's CRITICAL override (api-handlers/session/
|
|
248
|
+
* viability.ts:82-86) into this, the impl consumers actually MOUNT.
|
|
249
|
+
* An OAuth session whose callback set mfaVerified === false because no
|
|
250
|
+
* refresh_token was issued ALWAYS needs 2FA completion, whatever the
|
|
251
|
+
* tenant config says — completing 2FA is how it obtains one.
|
|
252
|
+
*
|
|
253
|
+
* STRICT === false, deliberately: an ABSENT mfaVerified (every session
|
|
254
|
+
* minted before that field existed) is not a signal, and treating it as
|
|
255
|
+
* one would force-rechallenge the entire existing session population.
|
|
256
|
+
*
|
|
257
|
+
* *** R807 (BAPert, card 362550) - WHY THE `=== false` TEST ALONE NO LONGER
|
|
258
|
+
* CARRIES THAT INTENT, AND WHAT RESTORES IT. ***
|
|
259
|
+
*
|
|
260
|
+
* The intent above is right, but by the time a session reaches here the
|
|
261
|
+
* distinction it depends on IS ALREADY GONE: every construction site
|
|
262
|
+
* (session-store, decode-session, token-lifecycle x2, SessionModel) collapses
|
|
263
|
+
* an absent mfaVerified to `false` via `?? false`. So `=== false` had stopped
|
|
264
|
+
* meaning "the callback affirmatively said false" and had come to mean
|
|
265
|
+
* "affirmatively false OR never knew" - i.e. the exact mass re-challenge this
|
|
266
|
+
* comment says it is preventing. The guard read as strict while behaving as
|
|
267
|
+
* permissive, because the collapse happens upstream and out of sight.
|
|
268
|
+
*
|
|
269
|
+
* R807 keeps `mfaVerified` fail-closed at all five sites (removing `?? false`
|
|
270
|
+
* would be an AUTH BYPASS - a session that never proved MFA read as having
|
|
271
|
+
* proved it) and gives THIS question its own signal instead. So the test is
|
|
272
|
+
* now two parts:
|
|
273
|
+
*
|
|
274
|
+
* mfaSignalPresent === true -> the producer really did emit a value, so the
|
|
275
|
+
* `false` below is a MEASUREMENT. Fire.
|
|
276
|
+
* absent / false -> LEGACY session (minted before the signal
|
|
277
|
+
* existed) or a path with no IDP tokens at all.
|
|
278
|
+
* NOT a signal. Do NOT force a re-challenge.
|
|
279
|
+
*
|
|
280
|
+
* That absent-means-do-not-fire read is the migration behaviour, and it is
|
|
281
|
+
* what preserves this comment's original guarantee verbatim through rollout:
|
|
282
|
+
* on the day this ships, EVERY existing session has no signal and none of them
|
|
283
|
+
* is re-challenged.
|
|
284
|
+
*
|
|
285
|
+
* *** AND IT IS SAFE ONLY BECAUSE IT IS CONFINED HERE. *** mfaSignalPresent is
|
|
286
|
+
* permissive by construction - it can only ever SUPPRESS this override. It must
|
|
287
|
+
* never reach a path that grants access; gate those on `mfaVerified`, which is
|
|
288
|
+
* still fail-closed everywhere. R807 is void if this signal escapes into an
|
|
289
|
+
* auth decision.
|
|
290
|
+
*
|
|
291
|
+
* Declared once here, like nextStep above, so the three authenticated
|
|
292
|
+
* responses cannot drift apart. Feeds the legacy `requires2FA` field the
|
|
293
|
+
* middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
|
|
294
|
+
* it names TENANT policy, and this override is not tenant state.
|
|
295
|
+
*/
|
|
296
|
+
const oauthSessionNeedsTwoFactor =
|
|
297
|
+
session?.mfaSignalPresent === true &&
|
|
298
|
+
session?.mfaVerified === false &&
|
|
299
|
+
!(session.idpRefreshToken || (session as any)?.refreshToken);
|
|
300
|
+
|
|
301
|
+
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
302
|
+
/*
|
|
303
|
+
* Resolve the four-state revocation status for this user (BAPert 66320).
|
|
304
|
+
* Read it HERE, above every authenticated return, so all three responses
|
|
305
|
+
* carry the SAME value - the same discipline as nextStep above.
|
|
306
|
+
*
|
|
307
|
+
* The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
|
|
308
|
+
* and always on 'revoked'; 'active' and 'not_configured' stay viable. A
|
|
309
|
+
* consumer that does NOT read revocationStatus is unaffected - absent is not
|
|
310
|
+
* revoked - so shipping this changes nothing for them until they opt in.
|
|
311
|
+
*/
|
|
312
|
+
let revocationStatus: RevocationStatus = 'not_configured';
|
|
313
|
+
if (session) {
|
|
314
|
+
// BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
|
|
315
|
+
// the token's own `sub` claim FIRST (session.userId can be an email).
|
|
316
|
+
const revocationUserId = resolveNumericIdpUserId(session, (session as any).idpAccessToken);
|
|
317
|
+
if (revocationUserId > 0) {
|
|
318
|
+
try {
|
|
319
|
+
revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
|
|
320
|
+
} catch {
|
|
321
|
+
// getRevocationStatus does not throw by contract, but never let a
|
|
322
|
+
// revocation probe take down the viability answer: fail closed.
|
|
323
|
+
revocationStatus = 'unknown';
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// N1 (NightHawk 66392): a session exists but carries no usable numeric
|
|
327
|
+
// IdP id, so the check could not run -> 'unknown' (could not determine),
|
|
328
|
+
// NOT 'not_configured' (no revocation source).
|
|
329
|
+
// BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
|
|
330
|
+
// id surfaces as a log line, not an unexplained sign-out.
|
|
331
|
+
revocationStatus = warnNonNumericUserId(sessionToken, session.userId);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check access token expiry
|
|
336
|
+
const now = Math.floor(Date.now() / 1000);
|
|
337
|
+
// 240654 addendum 2: fall back to the Redis session's own expiry.
|
|
338
|
+
//
|
|
339
|
+
// The first two reads come off the Better Auth session OBJECT. A Path-B
|
|
340
|
+
// synthetic session — the shape server/auth.ts builds for a legacy JWS
|
|
341
|
+
// session — carries NEITHER: it is `{session:{token}, user:{...}}` with no
|
|
342
|
+
// accessTokenExpires and no exp. So every legacy session fell into the
|
|
343
|
+
// "no expiry info" branch below and answered needsRefresh:true on EVERY
|
|
344
|
+
// viability read from the moment of login, while the token was in fact
|
|
345
|
+
// fresh (15-minute TTL measured). Benign for the viable/authenticated
|
|
346
|
+
// verdict, but it recommends a refresh constantly, which is pure perf
|
|
347
|
+
// noise and multiplies exposure to the empty-clientId refresh path (223542).
|
|
348
|
+
//
|
|
349
|
+
// lib/session-store puts the real value on the session as
|
|
350
|
+
// idpAccessTokenExpires, in MILLISECONDS (better-auth.ts:784 sets
|
|
351
|
+
// `Date.now() + expires_in * 1000`). The other two sources are in SECONDS.
|
|
352
|
+
// Mixing units here would be a silent disaster, so note that the
|
|
353
|
+
// normalisation below (`> 1e12 ? /1000 : as-is`) already discriminates
|
|
354
|
+
// them by magnitude: a seconds timestamp is ~1.7e9, a milliseconds one
|
|
355
|
+
// ~1.7e12. This fallback is therefore safe in either unit, and that
|
|
356
|
+
// existing normaliser is load-bearing for it -- do not remove it.
|
|
357
|
+
const accessTokenExpires =
|
|
358
|
+
(token as any).accessTokenExpires ||
|
|
359
|
+
token.exp ||
|
|
360
|
+
(session as any)?.idpAccessTokenExpires;
|
|
361
|
+
|
|
362
|
+
if (!accessTokenExpires) {
|
|
363
|
+
// No expiry info, assume viable but recommend refresh
|
|
364
|
+
const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
|
|
365
|
+
// Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
|
|
366
|
+
const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
|
|
367
|
+
|
|
368
|
+
// CRITICAL: Check if MFA has expired (2FA TTL enforcement)
|
|
369
|
+
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
370
|
+
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
371
|
+
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
372
|
+
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
373
|
+
|
|
374
|
+
// User has completed 2FA requirements if: they verified AND it hasn't expired
|
|
375
|
+
const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
|
|
376
|
+
|
|
377
|
+
// userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
|
|
378
|
+
const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
|
|
379
|
+
|
|
380
|
+
return NextResponse.json({
|
|
381
|
+
viable: true,
|
|
382
|
+
needsRefresh: true,
|
|
383
|
+
authenticated: true,
|
|
384
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
385
|
+
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
386
|
+
// boolean the middleware's existing force-invalid branch already reads.
|
|
387
|
+
revocationStatus,
|
|
388
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
389
|
+
// Clear names for middleware decision-making
|
|
390
|
+
tenantRequiresTwoFactor,
|
|
391
|
+
userHasCompletedTenantTwoFactorRequirements,
|
|
392
|
+
userStillNeedsTwoFactor,
|
|
393
|
+
tenantAllowed2FAMethods,
|
|
394
|
+
nextStep,
|
|
395
|
+
// Legacy field names for backwards compatibility
|
|
396
|
+
requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
|
|
397
|
+
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
398
|
+
accessTokenExpired: false,
|
|
399
|
+
reason: 'No expiry information'
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Convert to seconds if needed
|
|
404
|
+
const expiryTime = accessTokenExpires > 1000000000000
|
|
405
|
+
? Math.floor(accessTokenExpires / 1000)
|
|
406
|
+
: accessTokenExpires;
|
|
407
|
+
|
|
408
|
+
const expiresIn = expiryTime - now;
|
|
409
|
+
const isExpired = expiresIn <= 0;
|
|
410
|
+
const needsRefresh = expiresIn <= 300; // 5 minutes buffer
|
|
411
|
+
|
|
412
|
+
// Check if we have refresh capability (check normalized field name first)
|
|
413
|
+
const hasRefreshToken = !!(session?.idpRefreshToken || (session as any)?.refreshToken || (token as any).refreshToken);
|
|
414
|
+
|
|
415
|
+
// CLEAR NAMING: Tenant-wide 2FA requirement from client config
|
|
416
|
+
const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
|
|
417
|
+
// Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
|
|
418
|
+
const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
|
|
419
|
+
|
|
420
|
+
// CRITICAL: Check if MFA has expired (2FA TTL enforcement)
|
|
421
|
+
// The session may have mfaVerified=true from days ago, but if mfaExpiresAt
|
|
422
|
+
// has passed, we must treat 2FA as incomplete to force re-verification.
|
|
423
|
+
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
424
|
+
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
425
|
+
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
426
|
+
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
427
|
+
|
|
428
|
+
// DEBUG: Log what we're reading from the session
|
|
429
|
+
console.log('[VIABILITY] Session 2FA state:', {
|
|
430
|
+
sessionToken: tokenLogId(sessionToken),
|
|
431
|
+
'session.mfaVerified': session?.mfaVerified,
|
|
432
|
+
'session.twoFactorComplete': (session as any)?.twoFactorComplete,
|
|
433
|
+
mfaVerifiedInSession,
|
|
434
|
+
mfaExpiresAt,
|
|
435
|
+
mfaExpired,
|
|
436
|
+
hasRefreshToken,
|
|
437
|
+
'session.idpRefreshToken': !!session?.idpRefreshToken,
|
|
438
|
+
'session.refreshToken': !!(session as any)?.refreshToken,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// CLEAR NAMING: User has completed 2FA requirements if: they verified AND it hasn't expired
|
|
442
|
+
const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
|
|
443
|
+
|
|
444
|
+
// userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
|
|
445
|
+
const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
|
|
446
|
+
|
|
447
|
+
if (mfaExpired && mfaVerifiedInSession) {
|
|
448
|
+
console.warn('[VIABILITY] MFA expired - forcing 2FA re-verification');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (isExpired) {
|
|
452
|
+
return NextResponse.json({
|
|
453
|
+
viable: false,
|
|
454
|
+
needsRefresh: hasRefreshToken,
|
|
455
|
+
expiresIn: 0,
|
|
456
|
+
hasRefreshToken,
|
|
457
|
+
authenticated: true,
|
|
458
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
459
|
+
// Clear names
|
|
460
|
+
tenantRequiresTwoFactor,
|
|
461
|
+
userHasCompletedTenantTwoFactorRequirements,
|
|
462
|
+
userStillNeedsTwoFactor,
|
|
463
|
+
tenantAllowed2FAMethods,
|
|
464
|
+
nextStep,
|
|
465
|
+
// Legacy names for backwards compatibility
|
|
466
|
+
requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
|
|
467
|
+
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
468
|
+
accessTokenExpired: true,
|
|
469
|
+
reason: 'Token expired',
|
|
470
|
+
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
471
|
+
revocationStatus,
|
|
472
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
473
|
+
// RBAC fields
|
|
474
|
+
roles: session?.roles || [],
|
|
475
|
+
clientId: resolveClientId(session?.idpClientId),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return NextResponse.json({
|
|
480
|
+
viable: true,
|
|
481
|
+
needsRefresh,
|
|
482
|
+
expiresIn,
|
|
483
|
+
hasRefreshToken,
|
|
484
|
+
authenticated: true,
|
|
485
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
486
|
+
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
487
|
+
revocationStatus,
|
|
488
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
489
|
+
// Clear names
|
|
490
|
+
tenantRequiresTwoFactor,
|
|
491
|
+
userHasCompletedTenantTwoFactorRequirements,
|
|
492
|
+
userStillNeedsTwoFactor,
|
|
493
|
+
tenantAllowed2FAMethods,
|
|
494
|
+
nextStep,
|
|
495
|
+
// Legacy names for backwards compatibility
|
|
496
|
+
requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
|
|
497
|
+
twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
|
|
498
|
+
accessTokenExpired: false,
|
|
499
|
+
expiresAt: new Date(expiryTime * 1000).toISOString(),
|
|
500
|
+
// RBAC fields
|
|
501
|
+
roles: session?.roles || [],
|
|
502
|
+
clientId: resolveClientId(session?.idpClientId),
|
|
503
|
+
});
|
|
504
|
+
} catch (error) {
|
|
505
|
+
// PAY-1438: pre-auth probe - the exception text stays in the log, keyed by correlationId.
|
|
506
|
+
const correlationId = logExceptionWithCorrelation('[VIABILITY_ROUTE] Error checking session viability:', error);
|
|
507
|
+
return NextResponse.json({
|
|
508
|
+
viable: false,
|
|
509
|
+
needsRefresh: false,
|
|
510
|
+
authenticated: false,
|
|
511
|
+
// PAY-1882 step 4: an exception here means the status could not be read.
|
|
512
|
+
// Report 'unknown' (not a bare 500 with no revocation signal) so the
|
|
513
|
+
// middleware's fail-closed default applies rather than reading it as viable.
|
|
514
|
+
revocationStatus: 'unknown' as RevocationStatus,
|
|
515
|
+
error: 'Failed to check session',
|
|
516
|
+
code: 'INTERNAL_ERROR',
|
|
517
|
+
correlationId
|
|
518
|
+
}, { status: 500 });
|
|
519
|
+
}
|
|
520
520
|
}
|