@payez/next-mvp 4.8.1 → 4.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/POST-INSTALL.md +408 -408
- package/README.md +859 -859
- package/dist/api-handlers/admin/audit.js +13 -21
- package/dist/api-handlers/admin/sessions.js +59 -45
- package/dist/api-handlers/admin/stats.js +1 -1
- package/dist/api-handlers/admin/users.js +15 -23
- package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
- package/dist/api-handlers/admin/vibe-data.js +5 -0
- 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/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/dist/vibe/client.d.ts +1 -1
- package/dist/vibe/client.js +2 -1
- package/dist/vibe/filters.d.ts +36 -0
- package/dist/vibe/filters.js +47 -0
- package/dist/vibe/generic.js +2 -1
- 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 +223 -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 +341 -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 +240 -251
- package/src/api-handlers/admin/vibe-data.ts +338 -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/revocation-fields.ts +16 -16
- 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 +1141 -1141
- 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/service-unavailable/page.tsx +63 -63
- 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 +878 -877
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +209 -209
- package/src/vibe/filters.ts +52 -0
- package/src/vibe/generic.ts +340 -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
package/src/server/auth.ts
CHANGED
|
@@ -1,486 +1,486 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side auth utilities for Better Auth.
|
|
3
|
-
*
|
|
4
|
-
* All server-side auth flows go through the Better Auth instance returned by
|
|
5
|
-
* getAuthInstance(); use getSession(req) for the request-scoped session.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import 'server-only';
|
|
9
|
-
import { getBetterAuthInstance } from '../auth/better-auth';
|
|
10
|
-
import {
|
|
11
|
-
getSession as getRedisSession,
|
|
12
|
-
getBetterAuthSession as getBetterAuthRedisSession,
|
|
13
|
-
describeSessionKeyPresence,
|
|
14
|
-
type SessionData,
|
|
15
|
-
} from '../lib/session-store';
|
|
16
|
-
import { tokenLogId } from '../lib/token-log-id';
|
|
17
|
-
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
18
|
-
|
|
19
|
-
export type IdpTokenResult =
|
|
20
|
-
| { success: true; accessToken: string; sessionData: SessionData }
|
|
21
|
-
| { success: false; error: 'NO_SESSION'; terminal: true }
|
|
22
|
-
// Card 187871: a Better Auth session can exist with NO idpAccessToken yet
|
|
23
|
-
// because 2FA is required and not yet completed - the IDP never issued the
|
|
24
|
-
// full token. That is a RECOVERABLE state (route to 2FA), not a dead
|
|
25
|
-
// session. Only collapse it to NO_TOKEN/terminal when the session itself
|
|
26
|
-
// already claims 2FA is done (sessionData.mfaVerified === true) and there is
|
|
27
|
-
// still no token - that combination is genuinely broken.
|
|
28
|
-
| { success: false; error: 'NO_TOKEN'; terminal: true }
|
|
29
|
-
| { success: false; error: 'NO_TOKEN_PENDING_2FA'; terminal: false; sessionData: SessionData };
|
|
30
|
-
|
|
31
|
-
function buildSessionDataFromAuthSession(session: any): SessionData | null {
|
|
32
|
-
const user = session?.user;
|
|
33
|
-
if (!user?.id && !user?.email) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const expiresAt = session?.session?.expiresAt
|
|
38
|
-
? new Date(session.session.expiresAt).getTime()
|
|
39
|
-
: Date.now() + 24 * 60 * 60 * 1000;
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
userId: user.userId || user.id || '',
|
|
43
|
-
email: user.email || '',
|
|
44
|
-
name: user.name || undefined,
|
|
45
|
-
image: user.image || undefined,
|
|
46
|
-
roles: Array.isArray(user.roles) ? user.roles : [],
|
|
47
|
-
idpAccessToken: user.idpAccessToken,
|
|
48
|
-
idpRefreshToken: user.idpRefreshToken,
|
|
49
|
-
idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
|
|
50
|
-
mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
|
|
51
|
-
oauthProvider: user.oauthProvider,
|
|
52
|
-
idpClientId: user.idpClientId,
|
|
53
|
-
merchantId: user.merchantId,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
|
|
58
|
-
if (!sessionData) {
|
|
59
|
-
return session;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const enrichedSessionData = {
|
|
63
|
-
...sessionData,
|
|
64
|
-
...(sessionToken ? { sessionToken } : {}),
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
(session as any).sessionData = enrichedSessionData;
|
|
68
|
-
|
|
69
|
-
if (session?.user) {
|
|
70
|
-
const user = session.user as any;
|
|
71
|
-
user.userId = enrichedSessionData.userId || user.userId;
|
|
72
|
-
user.email = enrichedSessionData.email || user.email;
|
|
73
|
-
user.name = enrichedSessionData.name || user.name;
|
|
74
|
-
user.image = enrichedSessionData.image || user.image;
|
|
75
|
-
user.roles = enrichedSessionData.roles || user.roles || [];
|
|
76
|
-
user.idpAccessToken = enrichedSessionData.idpAccessToken;
|
|
77
|
-
user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
|
|
78
|
-
user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
|
|
79
|
-
user.mfaVerified = enrichedSessionData.mfaVerified;
|
|
80
|
-
user.twoFactorSessionVerified =
|
|
81
|
-
enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
|
|
82
|
-
user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
|
|
83
|
-
user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
|
|
84
|
-
user.merchantId = enrichedSessionData.merchantId || user.merchantId;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return session;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Get the initialized Better Auth instance (singleton).
|
|
92
|
-
*
|
|
93
|
-
* 244549: the single-flight memoisation lives in lib/single-flight.ts rather
|
|
94
|
-
* than inline here, and that placement is deliberate rather than tidiness.
|
|
95
|
-
* The bug being fixed -- a REJECTED init promise cached for the life of the
|
|
96
|
-
* process, so recovery was restart-only -- went unnoticed precisely because
|
|
97
|
-
* this module cannot be loaded in a unit test: importing it pulls in
|
|
98
|
-
* better-auth and the whole IDP config stack. Extracted into a zero-import
|
|
99
|
-
* module, the retry semantics are covered by
|
|
100
|
-
* test/single-flight-retry.test.cjs, which imports dist/ and therefore
|
|
101
|
-
* exercises the artefact that actually ships.
|
|
102
|
-
*
|
|
103
|
-
* PAY-1623 (package half, BAPert 62449): THIS FUNNEL NO LONGER BUILDS ITS OWN
|
|
104
|
-
* INSTANCE. It used to call `createBetterAuthInstance(config)` with no opts, so
|
|
105
|
-
* it carried NONE of the app-supplied plugins registered by the host app at
|
|
106
|
-
* startup (`configureBetterAuth()` -> `state.configuredOpts`). `samlAcs()` and
|
|
107
|
-
* `passkeySignIn()` arrive only that way, so `getAuthInstance()` returned an
|
|
108
|
-
* instance on which `auth.api.samlAcs` was `undefined` — the exact package twin
|
|
109
|
-
* of the portal ACS crash `t.api.samlAcs is not a function` (Jon's walk,
|
|
110
|
-
* 2026-09-21; Portal PR 517 fixed the portal by importing the CONFIGURED
|
|
111
|
-
* instance, `getBetterAuthInstance`, which is why this file must now resolve the
|
|
112
|
-
* same one). It also silently created a SECOND, divergent instance beside the
|
|
113
|
-
* one every /api/auth/* route uses — two instances, two secrets, one process.
|
|
114
|
-
*
|
|
115
|
-
* It now DELEGATES to that configured funnel, which is the single source of
|
|
116
|
-
* truth for the process's Better Auth instance:
|
|
117
|
-
* - it carries `state.configuredOpts` (better-auth.ts:930), so the app
|
|
118
|
-
* plugins are present;
|
|
119
|
-
* - its init is already single-flighted with the retry semantics described in
|
|
120
|
-
* lib/single-flight.ts (see getBetterAuthInstance, card 244549/338215), so
|
|
121
|
-
* this function keeps the same no-latch recovery contract;
|
|
122
|
-
* - there is now ONE instance, so the double-init / divergent-secret hazard
|
|
123
|
-
* this path used to create is gone by construction.
|
|
124
|
-
*/
|
|
125
|
-
export async function getAuthInstance() {
|
|
126
|
-
return getBetterAuthInstance();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
/* ----------------------------------------------------------------------------------------------------------------
|
|
131
|
-
* PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
|
|
132
|
-
*
|
|
133
|
-
* A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
|
|
134
|
-
* `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
|
|
135
|
-
* the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
|
|
136
|
-
* a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
|
|
137
|
-
* cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
|
|
138
|
-
* -------------------------------------------------------------------------------------------------------------- */
|
|
139
|
-
|
|
140
|
-
/** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
|
|
141
|
-
function scrubForLog(v: unknown): string {
|
|
142
|
-
const raw = v instanceof Error ? v.message : String(v ?? '');
|
|
143
|
-
return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** `catch {}` sites on the session read path: still swallowed, now audible. */
|
|
147
|
-
function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
|
|
148
|
-
try {
|
|
149
|
-
const name = scrubForLog(err instanceof Error ? err.name : typeof err);
|
|
150
|
-
console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
|
|
151
|
-
} catch {
|
|
152
|
-
/* a diagnostic must never change the outcome */
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
|
|
157
|
-
function logNoSessionOriginA(request: Request | undefined): void {
|
|
158
|
-
try {
|
|
159
|
-
const header = request?.headers?.get('cookie') ?? '';
|
|
160
|
-
// Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
|
|
161
|
-
// logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
|
|
162
|
-
const cookieNames = header
|
|
163
|
-
.split(';')
|
|
164
|
-
// Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
|
|
165
|
-
// value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
|
|
166
|
-
// is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
|
|
167
|
-
.filter((c) => {
|
|
168
|
-
const i = c.indexOf('=');
|
|
169
|
-
if (i < 0) return false;
|
|
170
|
-
const v = c.slice(i + 1);
|
|
171
|
-
return v !== '' && !/^=+$/.test(v);
|
|
172
|
-
})
|
|
173
|
-
.map((c) => c.split('=')[0].trim())
|
|
174
|
-
.filter((n) => n !== '')
|
|
175
|
-
// A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
|
|
176
|
-
// the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
|
|
177
|
-
// none is opaque, so it is logged as a placeholder, never as itself.
|
|
178
|
-
.map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
|
|
179
|
-
.slice(0, 20);
|
|
180
|
-
console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
|
|
181
|
-
} catch {
|
|
182
|
-
/* a diagnostic must never change the outcome */
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
|
|
187
|
-
function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
|
|
188
|
-
try {
|
|
189
|
-
void describeSessionKeyPresence(sessionToken).then(
|
|
190
|
-
(keys) => {
|
|
191
|
-
try {
|
|
192
|
-
console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
|
|
193
|
-
} catch {
|
|
194
|
-
/* never throws */
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
() => {
|
|
198
|
-
/* never throws */
|
|
199
|
-
}
|
|
200
|
-
);
|
|
201
|
-
} catch {
|
|
202
|
-
/* a diagnostic must never change the outcome */
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Get the current session from a request.
|
|
208
|
-
*
|
|
209
|
-
* Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
|
|
210
|
-
* cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
|
|
211
|
-
* secret and the canonical cookie parse) but does NOT decide whether a session
|
|
212
|
-
* is alive. If Better Auth's primary path returns null or a partial session
|
|
213
|
-
* (cookie cache miss, secondary storage eviction, token rotation), we fall
|
|
214
|
-
* back to manually extracting the session-token claim from the cookie and
|
|
215
|
-
* querying the canonical Redis store directly.
|
|
216
|
-
*
|
|
217
|
-
* This closes the asymmetric early-exit that caused contradictory answers
|
|
218
|
-
* within milliseconds in production traces:
|
|
219
|
-
* GET /api/session/viability → 200 (Redis: alive)
|
|
220
|
-
* GET /api/session/idp-token → 200 (Redis: alive)
|
|
221
|
-
* getFreshIdpToken → NO_SESSION (Better Auth cache miss)
|
|
222
|
-
*
|
|
223
|
-
* Returns the session object or null if not authenticated per Redis.
|
|
224
|
-
*
|
|
225
|
-
* PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
|
|
226
|
-
* Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
|
|
227
|
-
* unreachable store call resolveSessionState directly.
|
|
228
|
-
*/
|
|
229
|
-
export async function getSession(request?: Request): Promise<any> {
|
|
230
|
-
const state = await resolveSessionState(request);
|
|
231
|
-
return state.state === 'live' ? state.session : null;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
|
|
236
|
-
* non-live answers to `null`, and that collapse is the defect this type exists to undo.
|
|
237
|
-
*
|
|
238
|
-
* MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
|
|
239
|
-
* a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
|
|
240
|
-
* identical `200 No session found` for both. Clearing on that shape would log every live user out on an
|
|
241
|
-
* outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
|
|
242
|
-
*
|
|
243
|
-
* live - the session resolved. `session` is exactly what getSession() returns.
|
|
244
|
-
* missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
|
|
245
|
-
* store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
|
|
246
|
-
* nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
|
|
247
|
-
* a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
|
|
248
|
-
* unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
|
|
249
|
-
* so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
|
|
250
|
-
* none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
|
|
251
|
-
* Nothing to clear on our authority: a forged cookie never yields `missing`.
|
|
252
|
-
*
|
|
253
|
-
* The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
|
|
254
|
-
* either and an outage or a forged cookie becomes a clear.
|
|
255
|
-
*/
|
|
256
|
-
export type SessionState =
|
|
257
|
-
| { state: 'live'; session: any }
|
|
258
|
-
| { state: 'missing'; sessionToken: string }
|
|
259
|
-
| { state: 'unknown' }
|
|
260
|
-
| { state: 'none' };
|
|
261
|
-
|
|
262
|
-
export async function resolveSessionState(request?: Request): Promise<SessionState> {
|
|
263
|
-
const auth = await getAuthInstance();
|
|
264
|
-
if (!request) return { state: 'none' };
|
|
265
|
-
|
|
266
|
-
// Path A — Better Auth primary path (cookie cache → secondary storage).
|
|
267
|
-
// Fast happy-path when both Better Auth and Redis agree.
|
|
268
|
-
//
|
|
269
|
-
// PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
|
|
270
|
-
// could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
|
|
271
|
-
// `unknown`, not `none`.
|
|
272
|
-
let betterAuthSession: any = null;
|
|
273
|
-
let primaryThrew = false;
|
|
274
|
-
try {
|
|
275
|
-
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
276
|
-
} catch (err) {
|
|
277
|
-
primaryThrew = true;
|
|
278
|
-
logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (betterAuthSession?.session?.token && betterAuthSession?.user) {
|
|
282
|
-
const sessionToken = betterAuthSession.session.token as string;
|
|
283
|
-
let sessionData: SessionData | null = null;
|
|
284
|
-
try {
|
|
285
|
-
sessionData = await getRedisSession(sessionToken);
|
|
286
|
-
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
logSessionReadSwallowed('A', err); // Redis unavailable
|
|
289
|
-
}
|
|
290
|
-
if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
291
|
-
return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
|
|
295
|
-
// decode + Redis-canonical liveness check. This catches cases where the
|
|
296
|
-
// canonical app session at `{slug}:{token}` is still alive but Better
|
|
297
|
-
// Auth's `ba:{slug}:{token}` secondary record was evicted or the
|
|
298
|
-
// in-memory cookie cache returned stale-null.
|
|
299
|
-
//
|
|
300
|
-
// PAY-1476: this path used to take the token with NO signature check, on the
|
|
301
|
-
// theory that "Redis lookup is the validation". It is not: a leaked token has
|
|
302
|
-
// a live record, so the token string alone authenticated, with any garbage in
|
|
303
|
-
// the signature position, and a forged JWS too. The token now comes only from
|
|
304
|
-
// the shared verified resolver (HMAC or JWS signature checked first).
|
|
305
|
-
const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
|
|
306
|
-
// No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
|
|
307
|
-
// unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
|
|
308
|
-
if (!sessionToken) return primaryThrew ? { state: 'unknown' } : { state: 'none' };
|
|
309
|
-
|
|
310
|
-
// PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
|
|
311
|
-
// nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
|
|
312
|
-
let sessionData: SessionData | null = null;
|
|
313
|
-
try {
|
|
314
|
-
sessionData = await getRedisSession(sessionToken);
|
|
315
|
-
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
316
|
-
} catch (err) {
|
|
317
|
-
logSessionReadSwallowed('B', err); // Redis unavailable
|
|
318
|
-
return { state: 'unknown' };
|
|
319
|
-
}
|
|
320
|
-
if (!sessionData) return { state: 'missing', sessionToken };
|
|
321
|
-
|
|
322
|
-
// Synthesize a minimal session shape compatible with downstream callers.
|
|
323
|
-
// The `session.token` field is what `getFreshIdpToken` and other callers
|
|
324
|
-
// read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
|
|
325
|
-
// populated here even though Better Auth didn't give us a full session.
|
|
326
|
-
const synthetic = {
|
|
327
|
-
session: { token: sessionToken },
|
|
328
|
-
user: {
|
|
329
|
-
id: sessionData.userId,
|
|
330
|
-
email: sessionData.email,
|
|
331
|
-
roles: sessionData.roles,
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Get normalized session data for the current request.
|
|
339
|
-
*
|
|
340
|
-
* This prefers the app's Redis session because it carries the canonical
|
|
341
|
-
* IDP token, roles, and tenant-specific user identity used by app routes.
|
|
342
|
-
*/
|
|
343
|
-
export async function getSessionData(request?: Request): Promise<SessionData | null> {
|
|
344
|
-
const session = await getSession(request);
|
|
345
|
-
const sessionData =
|
|
346
|
-
((session as any)?.sessionData as SessionData | undefined) ||
|
|
347
|
-
buildSessionDataFromAuthSession(session);
|
|
348
|
-
|
|
349
|
-
if (!sessionData) {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const sessionToken = session?.session?.token as string | undefined;
|
|
354
|
-
return sessionToken
|
|
355
|
-
? { ...sessionData, sessionToken }
|
|
356
|
-
: sessionData;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Get the current request's IDP access token without triggering a refresh.
|
|
361
|
-
*
|
|
362
|
-
* Use this for routes that only need the currently-issued bearer token and
|
|
363
|
-
* should fail closed instead of performing token lifecycle work. For backend
|
|
364
|
-
* proxy routes that forward the token to a downstream API, prefer
|
|
365
|
-
* `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
|
|
366
|
-
* the proxy never sends a credential it already knows is invalid.
|
|
367
|
-
*/
|
|
368
|
-
export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
|
|
369
|
-
const sessionData = await getSessionData(request);
|
|
370
|
-
if (!sessionData) {
|
|
371
|
-
return { success: false, error: 'NO_SESSION', terminal: true };
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
|
|
375
|
-
if (!accessToken) {
|
|
376
|
-
// Card 187871: a live Better Auth session with no idpAccessToken and
|
|
377
|
-
// mfaVerified !== true is a user mid-2FA, not a dead session - the IDP
|
|
378
|
-
// withholds the full token until 2FA completes (idp-fetch.ts:81/113).
|
|
379
|
-
// Only treat this as terminal once the session itself claims 2FA is
|
|
380
|
-
// already done; a token missing AFTER that is genuinely broken.
|
|
381
|
-
if (sessionData.mfaVerified !== true) {
|
|
382
|
-
return { success: false, error: 'NO_TOKEN_PENDING_2FA', terminal: false, sessionData };
|
|
383
|
-
}
|
|
384
|
-
return { success: false, error: 'NO_TOKEN', terminal: true };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return {
|
|
388
|
-
success: true,
|
|
389
|
-
accessToken,
|
|
390
|
-
sessionData,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export type FreshIdpTokenResult =
|
|
395
|
-
| { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
|
|
396
|
-
| {
|
|
397
|
-
success: false;
|
|
398
|
-
error: string;
|
|
399
|
-
status: number;
|
|
400
|
-
terminal?: boolean;
|
|
401
|
-
discardToken?: boolean;
|
|
402
|
-
retryable?: boolean;
|
|
403
|
-
resolution?: string;
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
export interface FreshIdpTokenConfig {
|
|
407
|
-
idpBaseUrl: string;
|
|
408
|
-
clientId: string;
|
|
409
|
-
refreshEndpoint?: string;
|
|
410
|
-
/** Refresh if the access token is within this many ms of expiry. Default 60_000. */
|
|
411
|
-
safetyWindowMs?: number;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Get the current request's IDP access token, preflight-refreshing if it is
|
|
416
|
-
* expired or within the safety window. Single-flight via Redis lock, so
|
|
417
|
-
* concurrent calls on the same session share one IDP round-trip and one
|
|
418
|
-
* single-use refresh-token consumption.
|
|
419
|
-
*
|
|
420
|
-
* Use this in proxy routes. The returned `accessToken` is safe to forward to
|
|
421
|
-
* a downstream API without expecting a 401. If `success` is false, surface a
|
|
422
|
-
* 401/redirect — there is no recoverable token for this session.
|
|
423
|
-
*/
|
|
424
|
-
export async function getFreshIdpToken(
|
|
425
|
-
request: Request | undefined,
|
|
426
|
-
config: FreshIdpTokenConfig
|
|
427
|
-
): Promise<FreshIdpTokenResult> {
|
|
428
|
-
const session = await getSession(request);
|
|
429
|
-
const sessionToken = session?.session?.token as string | undefined;
|
|
430
|
-
if (!sessionToken) {
|
|
431
|
-
logNoSessionOriginA(request);
|
|
432
|
-
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
|
|
436
|
-
const result = await ensureFreshAccessToken(
|
|
437
|
-
sessionToken,
|
|
438
|
-
{
|
|
439
|
-
idpBaseUrl: config.idpBaseUrl,
|
|
440
|
-
clientId: config.clientId,
|
|
441
|
-
refreshEndpoint: config.refreshEndpoint,
|
|
442
|
-
},
|
|
443
|
-
{
|
|
444
|
-
safetyWindowMs: config.safetyWindowMs,
|
|
445
|
-
requestId: request?.headers?.get('x-request-id') ?? undefined,
|
|
446
|
-
}
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
if (!result.ok) {
|
|
450
|
-
if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
|
|
451
|
-
return {
|
|
452
|
-
success: false,
|
|
453
|
-
error: result.code,
|
|
454
|
-
status: result.status,
|
|
455
|
-
terminal: result.terminal,
|
|
456
|
-
discardToken: result.discardToken,
|
|
457
|
-
retryable: result.retryable,
|
|
458
|
-
resolution: result.resolution,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const sessionData = await getRedisSession(sessionToken);
|
|
463
|
-
if (!sessionData) {
|
|
464
|
-
logNoSessionWithKeys('c', sessionToken);
|
|
465
|
-
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return {
|
|
469
|
-
success: true,
|
|
470
|
-
accessToken: result.accessToken,
|
|
471
|
-
sessionData,
|
|
472
|
-
refreshed: result.refreshed,
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Get the current session, throwing if not authenticated.
|
|
478
|
-
* Use in API handlers that require auth.
|
|
479
|
-
*/
|
|
480
|
-
export async function requireSession(request: Request) {
|
|
481
|
-
const session = await getSession(request);
|
|
482
|
-
if (!session) {
|
|
483
|
-
throw new Error('Unauthorized');
|
|
484
|
-
}
|
|
485
|
-
return session;
|
|
486
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Server-side auth utilities for Better Auth.
|
|
3
|
+
*
|
|
4
|
+
* All server-side auth flows go through the Better Auth instance returned by
|
|
5
|
+
* getAuthInstance(); use getSession(req) for the request-scoped session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import 'server-only';
|
|
9
|
+
import { getBetterAuthInstance } from '../auth/better-auth';
|
|
10
|
+
import {
|
|
11
|
+
getSession as getRedisSession,
|
|
12
|
+
getBetterAuthSession as getBetterAuthRedisSession,
|
|
13
|
+
describeSessionKeyPresence,
|
|
14
|
+
type SessionData,
|
|
15
|
+
} from '../lib/session-store';
|
|
16
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
17
|
+
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
18
|
+
|
|
19
|
+
export type IdpTokenResult =
|
|
20
|
+
| { success: true; accessToken: string; sessionData: SessionData }
|
|
21
|
+
| { success: false; error: 'NO_SESSION'; terminal: true }
|
|
22
|
+
// Card 187871: a Better Auth session can exist with NO idpAccessToken yet
|
|
23
|
+
// because 2FA is required and not yet completed - the IDP never issued the
|
|
24
|
+
// full token. That is a RECOVERABLE state (route to 2FA), not a dead
|
|
25
|
+
// session. Only collapse it to NO_TOKEN/terminal when the session itself
|
|
26
|
+
// already claims 2FA is done (sessionData.mfaVerified === true) and there is
|
|
27
|
+
// still no token - that combination is genuinely broken.
|
|
28
|
+
| { success: false; error: 'NO_TOKEN'; terminal: true }
|
|
29
|
+
| { success: false; error: 'NO_TOKEN_PENDING_2FA'; terminal: false; sessionData: SessionData };
|
|
30
|
+
|
|
31
|
+
function buildSessionDataFromAuthSession(session: any): SessionData | null {
|
|
32
|
+
const user = session?.user;
|
|
33
|
+
if (!user?.id && !user?.email) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const expiresAt = session?.session?.expiresAt
|
|
38
|
+
? new Date(session.session.expiresAt).getTime()
|
|
39
|
+
: Date.now() + 24 * 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
userId: user.userId || user.id || '',
|
|
43
|
+
email: user.email || '',
|
|
44
|
+
name: user.name || undefined,
|
|
45
|
+
image: user.image || undefined,
|
|
46
|
+
roles: Array.isArray(user.roles) ? user.roles : [],
|
|
47
|
+
idpAccessToken: user.idpAccessToken,
|
|
48
|
+
idpRefreshToken: user.idpRefreshToken,
|
|
49
|
+
idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
|
|
50
|
+
mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
|
|
51
|
+
oauthProvider: user.oauthProvider,
|
|
52
|
+
idpClientId: user.idpClientId,
|
|
53
|
+
merchantId: user.merchantId,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
|
|
58
|
+
if (!sessionData) {
|
|
59
|
+
return session;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const enrichedSessionData = {
|
|
63
|
+
...sessionData,
|
|
64
|
+
...(sessionToken ? { sessionToken } : {}),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
(session as any).sessionData = enrichedSessionData;
|
|
68
|
+
|
|
69
|
+
if (session?.user) {
|
|
70
|
+
const user = session.user as any;
|
|
71
|
+
user.userId = enrichedSessionData.userId || user.userId;
|
|
72
|
+
user.email = enrichedSessionData.email || user.email;
|
|
73
|
+
user.name = enrichedSessionData.name || user.name;
|
|
74
|
+
user.image = enrichedSessionData.image || user.image;
|
|
75
|
+
user.roles = enrichedSessionData.roles || user.roles || [];
|
|
76
|
+
user.idpAccessToken = enrichedSessionData.idpAccessToken;
|
|
77
|
+
user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
|
|
78
|
+
user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
|
|
79
|
+
user.mfaVerified = enrichedSessionData.mfaVerified;
|
|
80
|
+
user.twoFactorSessionVerified =
|
|
81
|
+
enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
|
|
82
|
+
user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
|
|
83
|
+
user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
|
|
84
|
+
user.merchantId = enrichedSessionData.merchantId || user.merchantId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return session;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the initialized Better Auth instance (singleton).
|
|
92
|
+
*
|
|
93
|
+
* 244549: the single-flight memoisation lives in lib/single-flight.ts rather
|
|
94
|
+
* than inline here, and that placement is deliberate rather than tidiness.
|
|
95
|
+
* The bug being fixed -- a REJECTED init promise cached for the life of the
|
|
96
|
+
* process, so recovery was restart-only -- went unnoticed precisely because
|
|
97
|
+
* this module cannot be loaded in a unit test: importing it pulls in
|
|
98
|
+
* better-auth and the whole IDP config stack. Extracted into a zero-import
|
|
99
|
+
* module, the retry semantics are covered by
|
|
100
|
+
* test/single-flight-retry.test.cjs, which imports dist/ and therefore
|
|
101
|
+
* exercises the artefact that actually ships.
|
|
102
|
+
*
|
|
103
|
+
* PAY-1623 (package half, BAPert 62449): THIS FUNNEL NO LONGER BUILDS ITS OWN
|
|
104
|
+
* INSTANCE. It used to call `createBetterAuthInstance(config)` with no opts, so
|
|
105
|
+
* it carried NONE of the app-supplied plugins registered by the host app at
|
|
106
|
+
* startup (`configureBetterAuth()` -> `state.configuredOpts`). `samlAcs()` and
|
|
107
|
+
* `passkeySignIn()` arrive only that way, so `getAuthInstance()` returned an
|
|
108
|
+
* instance on which `auth.api.samlAcs` was `undefined` — the exact package twin
|
|
109
|
+
* of the portal ACS crash `t.api.samlAcs is not a function` (Jon's walk,
|
|
110
|
+
* 2026-09-21; Portal PR 517 fixed the portal by importing the CONFIGURED
|
|
111
|
+
* instance, `getBetterAuthInstance`, which is why this file must now resolve the
|
|
112
|
+
* same one). It also silently created a SECOND, divergent instance beside the
|
|
113
|
+
* one every /api/auth/* route uses — two instances, two secrets, one process.
|
|
114
|
+
*
|
|
115
|
+
* It now DELEGATES to that configured funnel, which is the single source of
|
|
116
|
+
* truth for the process's Better Auth instance:
|
|
117
|
+
* - it carries `state.configuredOpts` (better-auth.ts:930), so the app
|
|
118
|
+
* plugins are present;
|
|
119
|
+
* - its init is already single-flighted with the retry semantics described in
|
|
120
|
+
* lib/single-flight.ts (see getBetterAuthInstance, card 244549/338215), so
|
|
121
|
+
* this function keeps the same no-latch recovery contract;
|
|
122
|
+
* - there is now ONE instance, so the double-init / divergent-secret hazard
|
|
123
|
+
* this path used to create is gone by construction.
|
|
124
|
+
*/
|
|
125
|
+
export async function getAuthInstance() {
|
|
126
|
+
return getBetterAuthInstance();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/* ----------------------------------------------------------------------------------------------------------------
|
|
131
|
+
* PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
|
|
132
|
+
*
|
|
133
|
+
* A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
|
|
134
|
+
* `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
|
|
135
|
+
* the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
|
|
136
|
+
* a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
|
|
137
|
+
* cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
|
|
138
|
+
* -------------------------------------------------------------------------------------------------------------- */
|
|
139
|
+
|
|
140
|
+
/** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
|
|
141
|
+
function scrubForLog(v: unknown): string {
|
|
142
|
+
const raw = v instanceof Error ? v.message : String(v ?? '');
|
|
143
|
+
return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `catch {}` sites on the session read path: still swallowed, now audible. */
|
|
147
|
+
function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
|
|
148
|
+
try {
|
|
149
|
+
const name = scrubForLog(err instanceof Error ? err.name : typeof err);
|
|
150
|
+
console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
|
|
151
|
+
} catch {
|
|
152
|
+
/* a diagnostic must never change the outcome */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
|
|
157
|
+
function logNoSessionOriginA(request: Request | undefined): void {
|
|
158
|
+
try {
|
|
159
|
+
const header = request?.headers?.get('cookie') ?? '';
|
|
160
|
+
// Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
|
|
161
|
+
// logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
|
|
162
|
+
const cookieNames = header
|
|
163
|
+
.split(';')
|
|
164
|
+
// Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
|
|
165
|
+
// value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
|
|
166
|
+
// is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
|
|
167
|
+
.filter((c) => {
|
|
168
|
+
const i = c.indexOf('=');
|
|
169
|
+
if (i < 0) return false;
|
|
170
|
+
const v = c.slice(i + 1);
|
|
171
|
+
return v !== '' && !/^=+$/.test(v);
|
|
172
|
+
})
|
|
173
|
+
.map((c) => c.split('=')[0].trim())
|
|
174
|
+
.filter((n) => n !== '')
|
|
175
|
+
// A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
|
|
176
|
+
// the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
|
|
177
|
+
// none is opaque, so it is logged as a placeholder, never as itself.
|
|
178
|
+
.map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
|
|
179
|
+
.slice(0, 20);
|
|
180
|
+
console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
|
|
181
|
+
} catch {
|
|
182
|
+
/* a diagnostic must never change the outcome */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
|
|
187
|
+
function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
|
|
188
|
+
try {
|
|
189
|
+
void describeSessionKeyPresence(sessionToken).then(
|
|
190
|
+
(keys) => {
|
|
191
|
+
try {
|
|
192
|
+
console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
|
|
193
|
+
} catch {
|
|
194
|
+
/* never throws */
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
() => {
|
|
198
|
+
/* never throws */
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
} catch {
|
|
202
|
+
/* a diagnostic must never change the outcome */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get the current session from a request.
|
|
208
|
+
*
|
|
209
|
+
* Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
|
|
210
|
+
* cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
|
|
211
|
+
* secret and the canonical cookie parse) but does NOT decide whether a session
|
|
212
|
+
* is alive. If Better Auth's primary path returns null or a partial session
|
|
213
|
+
* (cookie cache miss, secondary storage eviction, token rotation), we fall
|
|
214
|
+
* back to manually extracting the session-token claim from the cookie and
|
|
215
|
+
* querying the canonical Redis store directly.
|
|
216
|
+
*
|
|
217
|
+
* This closes the asymmetric early-exit that caused contradictory answers
|
|
218
|
+
* within milliseconds in production traces:
|
|
219
|
+
* GET /api/session/viability → 200 (Redis: alive)
|
|
220
|
+
* GET /api/session/idp-token → 200 (Redis: alive)
|
|
221
|
+
* getFreshIdpToken → NO_SESSION (Better Auth cache miss)
|
|
222
|
+
*
|
|
223
|
+
* Returns the session object or null if not authenticated per Redis.
|
|
224
|
+
*
|
|
225
|
+
* PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
|
|
226
|
+
* Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
|
|
227
|
+
* unreachable store call resolveSessionState directly.
|
|
228
|
+
*/
|
|
229
|
+
export async function getSession(request?: Request): Promise<any> {
|
|
230
|
+
const state = await resolveSessionState(request);
|
|
231
|
+
return state.state === 'live' ? state.session : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
|
|
236
|
+
* non-live answers to `null`, and that collapse is the defect this type exists to undo.
|
|
237
|
+
*
|
|
238
|
+
* MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
|
|
239
|
+
* a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
|
|
240
|
+
* identical `200 No session found` for both. Clearing on that shape would log every live user out on an
|
|
241
|
+
* outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
|
|
242
|
+
*
|
|
243
|
+
* live - the session resolved. `session` is exactly what getSession() returns.
|
|
244
|
+
* missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
|
|
245
|
+
* store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
|
|
246
|
+
* nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
|
|
247
|
+
* a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
|
|
248
|
+
* unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
|
|
249
|
+
* so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
|
|
250
|
+
* none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
|
|
251
|
+
* Nothing to clear on our authority: a forged cookie never yields `missing`.
|
|
252
|
+
*
|
|
253
|
+
* The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
|
|
254
|
+
* either and an outage or a forged cookie becomes a clear.
|
|
255
|
+
*/
|
|
256
|
+
export type SessionState =
|
|
257
|
+
| { state: 'live'; session: any }
|
|
258
|
+
| { state: 'missing'; sessionToken: string }
|
|
259
|
+
| { state: 'unknown' }
|
|
260
|
+
| { state: 'none' };
|
|
261
|
+
|
|
262
|
+
export async function resolveSessionState(request?: Request): Promise<SessionState> {
|
|
263
|
+
const auth = await getAuthInstance();
|
|
264
|
+
if (!request) return { state: 'none' };
|
|
265
|
+
|
|
266
|
+
// Path A — Better Auth primary path (cookie cache → secondary storage).
|
|
267
|
+
// Fast happy-path when both Better Auth and Redis agree.
|
|
268
|
+
//
|
|
269
|
+
// PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
|
|
270
|
+
// could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
|
|
271
|
+
// `unknown`, not `none`.
|
|
272
|
+
let betterAuthSession: any = null;
|
|
273
|
+
let primaryThrew = false;
|
|
274
|
+
try {
|
|
275
|
+
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
276
|
+
} catch (err) {
|
|
277
|
+
primaryThrew = true;
|
|
278
|
+
logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (betterAuthSession?.session?.token && betterAuthSession?.user) {
|
|
282
|
+
const sessionToken = betterAuthSession.session.token as string;
|
|
283
|
+
let sessionData: SessionData | null = null;
|
|
284
|
+
try {
|
|
285
|
+
sessionData = await getRedisSession(sessionToken);
|
|
286
|
+
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
logSessionReadSwallowed('A', err); // Redis unavailable
|
|
289
|
+
}
|
|
290
|
+
if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
291
|
+
return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
|
|
295
|
+
// decode + Redis-canonical liveness check. This catches cases where the
|
|
296
|
+
// canonical app session at `{slug}:{token}` is still alive but Better
|
|
297
|
+
// Auth's `ba:{slug}:{token}` secondary record was evicted or the
|
|
298
|
+
// in-memory cookie cache returned stale-null.
|
|
299
|
+
//
|
|
300
|
+
// PAY-1476: this path used to take the token with NO signature check, on the
|
|
301
|
+
// theory that "Redis lookup is the validation". It is not: a leaked token has
|
|
302
|
+
// a live record, so the token string alone authenticated, with any garbage in
|
|
303
|
+
// the signature position, and a forged JWS too. The token now comes only from
|
|
304
|
+
// the shared verified resolver (HMAC or JWS signature checked first).
|
|
305
|
+
const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
|
|
306
|
+
// No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
|
|
307
|
+
// unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
|
|
308
|
+
if (!sessionToken) return primaryThrew ? { state: 'unknown' } : { state: 'none' };
|
|
309
|
+
|
|
310
|
+
// PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
|
|
311
|
+
// nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
|
|
312
|
+
let sessionData: SessionData | null = null;
|
|
313
|
+
try {
|
|
314
|
+
sessionData = await getRedisSession(sessionToken);
|
|
315
|
+
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
logSessionReadSwallowed('B', err); // Redis unavailable
|
|
318
|
+
return { state: 'unknown' };
|
|
319
|
+
}
|
|
320
|
+
if (!sessionData) return { state: 'missing', sessionToken };
|
|
321
|
+
|
|
322
|
+
// Synthesize a minimal session shape compatible with downstream callers.
|
|
323
|
+
// The `session.token` field is what `getFreshIdpToken` and other callers
|
|
324
|
+
// read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
|
|
325
|
+
// populated here even though Better Auth didn't give us a full session.
|
|
326
|
+
const synthetic = {
|
|
327
|
+
session: { token: sessionToken },
|
|
328
|
+
user: {
|
|
329
|
+
id: sessionData.userId,
|
|
330
|
+
email: sessionData.email,
|
|
331
|
+
roles: sessionData.roles,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get normalized session data for the current request.
|
|
339
|
+
*
|
|
340
|
+
* This prefers the app's Redis session because it carries the canonical
|
|
341
|
+
* IDP token, roles, and tenant-specific user identity used by app routes.
|
|
342
|
+
*/
|
|
343
|
+
export async function getSessionData(request?: Request): Promise<SessionData | null> {
|
|
344
|
+
const session = await getSession(request);
|
|
345
|
+
const sessionData =
|
|
346
|
+
((session as any)?.sessionData as SessionData | undefined) ||
|
|
347
|
+
buildSessionDataFromAuthSession(session);
|
|
348
|
+
|
|
349
|
+
if (!sessionData) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const sessionToken = session?.session?.token as string | undefined;
|
|
354
|
+
return sessionToken
|
|
355
|
+
? { ...sessionData, sessionToken }
|
|
356
|
+
: sessionData;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Get the current request's IDP access token without triggering a refresh.
|
|
361
|
+
*
|
|
362
|
+
* Use this for routes that only need the currently-issued bearer token and
|
|
363
|
+
* should fail closed instead of performing token lifecycle work. For backend
|
|
364
|
+
* proxy routes that forward the token to a downstream API, prefer
|
|
365
|
+
* `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
|
|
366
|
+
* the proxy never sends a credential it already knows is invalid.
|
|
367
|
+
*/
|
|
368
|
+
export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
|
|
369
|
+
const sessionData = await getSessionData(request);
|
|
370
|
+
if (!sessionData) {
|
|
371
|
+
return { success: false, error: 'NO_SESSION', terminal: true };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
|
|
375
|
+
if (!accessToken) {
|
|
376
|
+
// Card 187871: a live Better Auth session with no idpAccessToken and
|
|
377
|
+
// mfaVerified !== true is a user mid-2FA, not a dead session - the IDP
|
|
378
|
+
// withholds the full token until 2FA completes (idp-fetch.ts:81/113).
|
|
379
|
+
// Only treat this as terminal once the session itself claims 2FA is
|
|
380
|
+
// already done; a token missing AFTER that is genuinely broken.
|
|
381
|
+
if (sessionData.mfaVerified !== true) {
|
|
382
|
+
return { success: false, error: 'NO_TOKEN_PENDING_2FA', terminal: false, sessionData };
|
|
383
|
+
}
|
|
384
|
+
return { success: false, error: 'NO_TOKEN', terminal: true };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
success: true,
|
|
389
|
+
accessToken,
|
|
390
|
+
sessionData,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export type FreshIdpTokenResult =
|
|
395
|
+
| { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
|
|
396
|
+
| {
|
|
397
|
+
success: false;
|
|
398
|
+
error: string;
|
|
399
|
+
status: number;
|
|
400
|
+
terminal?: boolean;
|
|
401
|
+
discardToken?: boolean;
|
|
402
|
+
retryable?: boolean;
|
|
403
|
+
resolution?: string;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
export interface FreshIdpTokenConfig {
|
|
407
|
+
idpBaseUrl: string;
|
|
408
|
+
clientId: string;
|
|
409
|
+
refreshEndpoint?: string;
|
|
410
|
+
/** Refresh if the access token is within this many ms of expiry. Default 60_000. */
|
|
411
|
+
safetyWindowMs?: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Get the current request's IDP access token, preflight-refreshing if it is
|
|
416
|
+
* expired or within the safety window. Single-flight via Redis lock, so
|
|
417
|
+
* concurrent calls on the same session share one IDP round-trip and one
|
|
418
|
+
* single-use refresh-token consumption.
|
|
419
|
+
*
|
|
420
|
+
* Use this in proxy routes. The returned `accessToken` is safe to forward to
|
|
421
|
+
* a downstream API without expecting a 401. If `success` is false, surface a
|
|
422
|
+
* 401/redirect — there is no recoverable token for this session.
|
|
423
|
+
*/
|
|
424
|
+
export async function getFreshIdpToken(
|
|
425
|
+
request: Request | undefined,
|
|
426
|
+
config: FreshIdpTokenConfig
|
|
427
|
+
): Promise<FreshIdpTokenResult> {
|
|
428
|
+
const session = await getSession(request);
|
|
429
|
+
const sessionToken = session?.session?.token as string | undefined;
|
|
430
|
+
if (!sessionToken) {
|
|
431
|
+
logNoSessionOriginA(request);
|
|
432
|
+
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
|
|
436
|
+
const result = await ensureFreshAccessToken(
|
|
437
|
+
sessionToken,
|
|
438
|
+
{
|
|
439
|
+
idpBaseUrl: config.idpBaseUrl,
|
|
440
|
+
clientId: config.clientId,
|
|
441
|
+
refreshEndpoint: config.refreshEndpoint,
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
safetyWindowMs: config.safetyWindowMs,
|
|
445
|
+
requestId: request?.headers?.get('x-request-id') ?? undefined,
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
if (!result.ok) {
|
|
450
|
+
if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
error: result.code,
|
|
454
|
+
status: result.status,
|
|
455
|
+
terminal: result.terminal,
|
|
456
|
+
discardToken: result.discardToken,
|
|
457
|
+
retryable: result.retryable,
|
|
458
|
+
resolution: result.resolution,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const sessionData = await getRedisSession(sessionToken);
|
|
463
|
+
if (!sessionData) {
|
|
464
|
+
logNoSessionWithKeys('c', sessionToken);
|
|
465
|
+
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
success: true,
|
|
470
|
+
accessToken: result.accessToken,
|
|
471
|
+
sessionData,
|
|
472
|
+
refreshed: result.refreshed,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Get the current session, throwing if not authenticated.
|
|
478
|
+
* Use in API handlers that require auth.
|
|
479
|
+
*/
|
|
480
|
+
export async function requireSession(request: Request) {
|
|
481
|
+
const session = await getSession(request);
|
|
482
|
+
if (!session) {
|
|
483
|
+
throw new Error('Unauthorized');
|
|
484
|
+
}
|
|
485
|
+
return session;
|
|
486
|
+
}
|