@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/lib/session-store.ts
CHANGED
|
@@ -1,1092 +1,1092 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session Store for `@payez/next-mvp` using ioredis
|
|
3
|
-
*
|
|
4
|
-
* This module provides a Redis-backed session store that is compatible with the
|
|
5
|
-
* `ioredis` client. It handles the creation, retrieval, and deletion of
|
|
6
|
-
* session data, which is the single source of truth for authentication.
|
|
7
|
-
*
|
|
8
|
-
* Includes advanced distributed refresh coordination with version control.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import redis from './redis';
|
|
12
|
-
import { randomBytes } from 'crypto';
|
|
13
|
-
import { SessionData } from '../models/SessionModel';
|
|
14
|
-
import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
|
|
15
|
-
import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
16
|
-
import { tokenLogId } from './token-log-id';
|
|
17
|
-
|
|
18
|
-
// Re-export SessionData for consumers
|
|
19
|
-
export type { SessionData } from '../models/SessionModel';
|
|
20
|
-
|
|
21
|
-
// Use app-slug prefixes for multi-app isolation
|
|
22
|
-
const getSessionKey = (token: string) => `${getSessionPrefix()}${token}`;
|
|
23
|
-
const getRefreshLockKey = (token: string) => `${getRefreshLockPrefix()}${token}`;
|
|
24
|
-
const getSessionVersionKey = (token: string) => `${getSessionPrefix()}ver:${token}`;
|
|
25
|
-
|
|
26
|
-
// Better Auth uses a different key format: ba:{appSlug}:{token}
|
|
27
|
-
const getBetterAuthSessionKey = (token: string, appSlug?: string) => `ba:${appSlug || 'app'}:${token}`;
|
|
28
|
-
|
|
29
|
-
const REFRESH_LOCK_TTL = 60; // 60 seconds
|
|
30
|
-
// 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
|
|
31
|
-
// of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
|
|
32
|
-
// two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
|
|
33
|
-
export const SESSION_TTL = 3 * 24 * 60 * 60;
|
|
34
|
-
|
|
35
|
-
export interface RefreshLockInfo {
|
|
36
|
-
sessionToken: string;
|
|
37
|
-
acquiredAt: number;
|
|
38
|
-
acquiredBy: string; // request ID or process identifier
|
|
39
|
-
lockVersion: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Generates a new session token.
|
|
44
|
-
* @returns A new session token string.
|
|
45
|
-
*/
|
|
46
|
-
export function generateSessionToken(): string {
|
|
47
|
-
return randomBytes(32).toString('hex');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Creates a new session in Redis.
|
|
52
|
-
*
|
|
53
|
-
* @param data The session data to store.
|
|
54
|
-
* @returns The generated session token (redisSessionId).
|
|
55
|
-
*/
|
|
56
|
-
export async function createSession(data: SessionData): Promise<string> {
|
|
57
|
-
const sessionToken = randomBytes(32).toString('hex');
|
|
58
|
-
const key = getSessionKey(sessionToken);
|
|
59
|
-
const versionKey = getSessionVersionKey(sessionToken);
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
await redis.multi()
|
|
63
|
-
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
64
|
-
.setex(versionKey, SESSION_TTL, '1')
|
|
65
|
-
.exec();
|
|
66
|
-
} catch (error) {
|
|
67
|
-
console.error('[SESSION-STORE] Failed to create session:', error);
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return sessionToken;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Retrieves a session from Redis.
|
|
76
|
-
*
|
|
77
|
-
* @param sessionToken The session token (redisSessionId) to look up.
|
|
78
|
-
* @returns The session data, or null if not found.
|
|
79
|
-
*/
|
|
80
|
-
export async function getSession(sessionToken: string): Promise<SessionData | null> {
|
|
81
|
-
if (!sessionToken) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
const key = getSessionKey(sessionToken);
|
|
85
|
-
const json = await redis.get(key);
|
|
86
|
-
if (!json) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
try {
|
|
90
|
-
return JSON.parse(json) as SessionData;
|
|
91
|
-
} catch {
|
|
92
|
-
console.error('[SESSION-STORE] Failed to parse session data');
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Retrieves a Better Auth session from Redis.
|
|
99
|
-
* Better Auth uses key format: ba:{appSlug}:{token}
|
|
100
|
-
*
|
|
101
|
-
* @param sessionToken The session token to look up.
|
|
102
|
-
* @param appSlug The app slug. Defaults to getAppSlug() - the SAME source Better Auth
|
|
103
|
-
* writes under - so reader and writer cannot disagree. Pass explicitly
|
|
104
|
-
* only if a consumer sets createBetterAuthInstance's clientSlug to
|
|
105
|
-
* something other than getAppSlug().
|
|
106
|
-
* @returns The session data, or null if not found.
|
|
107
|
-
*/
|
|
108
|
-
export async function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null> {
|
|
109
|
-
if (!sessionToken) {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
// 241875 / 213416 (partially-applied fix, completed here): this line used to be
|
|
113
|
-
// appSlug || process.env.CLIENT_ID || 'idealvibe_online'
|
|
114
|
-
// which is a THIRD slug resolution, disagreeing with the two canonical ones in
|
|
115
|
-
// this same file - getSessionKey() uses getSessionPrefix() (i.e. getAppSlug()),
|
|
116
|
-
// and deleteSessionEverywhere() uses `appSlug || getAppSlug()`.
|
|
117
|
-
//
|
|
118
|
-
// WRITER vs READER, same keyspace, different answers. Better Auth WRITES
|
|
119
|
-
// ba:{getAppSlug()}:{token} - deleteSessionEverywhere's own docstring records
|
|
120
|
-
// that it mirrors createBetterAuthInstance's `config.clientSlug || getAppSlug()`.
|
|
121
|
-
// This function READ ba:{CLIENT_ID or 'idealvibe_online'}:{token}.
|
|
122
|
-
//
|
|
123
|
-
// MEASURED IN PRODUCTION (keelauth-portal-config, 2026-08-24): APP_SLUG is
|
|
124
|
-
// declared and CLIENT_ID is NOT. So the old chain fell through to the literal and
|
|
125
|
-
// every one of this function's 14 call sites looked under a key Better Auth never
|
|
126
|
-
// writes. It could not match, and it returned null silently rather than failing -
|
|
127
|
-
// so the BA-session fallback documented in 4.1.3 (viability falling back to BA
|
|
128
|
-
// Redis when the canonical store misses) has been a safety net with a hole in it.
|
|
129
|
-
//
|
|
130
|
-
// 213416 fixed this exact writer/reader split at deleteSessionEverywhere and did
|
|
131
|
-
// not reach here. Using getAppSlug() makes the two agree by construction.
|
|
132
|
-
const slug = appSlug || getAppSlug();
|
|
133
|
-
const key = getBetterAuthSessionKey(sessionToken, slug);
|
|
134
|
-
const json = await redis.get(key);
|
|
135
|
-
if (!json) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
const data = JSON.parse(json);
|
|
140
|
-
// Better Auth stores the session differently - extract user data
|
|
141
|
-
if (data.user) {
|
|
142
|
-
return {
|
|
143
|
-
userId: data.user.id || data.user.email,
|
|
144
|
-
email: data.user.email,
|
|
145
|
-
name: data.user.name,
|
|
146
|
-
image: data.user.image,
|
|
147
|
-
idpAccessToken: data.idpTokens?.idpAccessToken,
|
|
148
|
-
idpRefreshToken: data.idpTokens?.idpRefreshToken,
|
|
149
|
-
idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
|
|
150
|
-
mfaVerified: data.idpTokens?.mfaVerified ?? false,
|
|
151
|
-
// R807 (362550): the `?? false` above is CORRECT and stays - fail-closed for
|
|
152
|
-
// every security consumer. This records the bit it destroys, for viability
|
|
153
|
-
// only. Note `!== undefined`, not a truthiness test: an explicit `false` IS
|
|
154
|
-
// a signal, and is precisely the case viability needs to see.
|
|
155
|
-
mfaSignalPresent: data.idpTokens?.mfaVerified !== undefined,
|
|
156
|
-
// Cards 141688/141741 — the IDP's routing decision, carried not derived.
|
|
157
|
-
// `null` is a real value here (IDP said nothing is outstanding), so it
|
|
158
|
-
// must stay distinguishable from `undefined` (we never captured it —
|
|
159
|
-
// true of every session minted before this change). No `?? null`:
|
|
160
|
-
// defaulting would turn "unknown" into "fully authenticated" and
|
|
161
|
-
// fail open on the existing session population.
|
|
162
|
-
nextStep: data.idpTokens?.nextStep,
|
|
163
|
-
roles: data.idpTokens?.roles || [],
|
|
164
|
-
idpClientId: data.idpTokens?.idpClientId ?? data.idpTokens?.clientId ?? data.idpClientId,
|
|
165
|
-
merchantId: data.idpTokens?.merchantId ?? data.merchantId,
|
|
166
|
-
} as SessionData;
|
|
167
|
-
}
|
|
168
|
-
return data as SessionData;
|
|
169
|
-
} catch {
|
|
170
|
-
console.error('[SESSION-STORE] Failed to parse Better Auth session data');
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Refresh session TTL without reading/writing data (sliding window expiry).
|
|
177
|
-
*/
|
|
178
|
-
export async function touchSession(token: string): Promise<void> {
|
|
179
|
-
const key = getSessionKey(token);
|
|
180
|
-
await redis.expire(key, SESSION_TTL);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Retrieves a session along with a version identifier for optimistic locking.
|
|
185
|
-
* @param sessionToken The session token to look up.
|
|
186
|
-
* @returns An object with session and version, or null if not found.
|
|
187
|
-
*/
|
|
188
|
-
export async function getSessionWithVersion(sessionToken: string): Promise<{ session: SessionData; version: string } | null> {
|
|
189
|
-
const session = await getSession(sessionToken);
|
|
190
|
-
if (!session) {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const versionKey = getSessionVersionKey(sessionToken);
|
|
195
|
-
const version = await redis.get(versionKey);
|
|
196
|
-
|
|
197
|
-
if (!version) {
|
|
198
|
-
// Session exists but version key missing - use idpAccessTokenExpires as fallback
|
|
199
|
-
const fallbackVersion = String(session.idpAccessTokenExpires || Date.now());
|
|
200
|
-
return { session, version: fallbackVersion };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return { session, version };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Checks if the access token in a session is still fresh (not expired).
|
|
208
|
-
* @param sessionToken The session token to check.
|
|
209
|
-
* @param currentAccessToken The current access token to compare.
|
|
210
|
-
* @param currentVersion Optional version to check for changes.
|
|
211
|
-
* @returns Object with freshness status and latest token info.
|
|
212
|
-
*/
|
|
213
|
-
export async function isAccessTokenFresh(
|
|
214
|
-
sessionToken: string,
|
|
215
|
-
currentAccessToken: string,
|
|
216
|
-
currentVersion?: string
|
|
217
|
-
): Promise<{
|
|
218
|
-
isFresh: boolean;
|
|
219
|
-
latestAccessToken?: string;
|
|
220
|
-
latestVersion?: string;
|
|
221
|
-
versionChanged: boolean;
|
|
222
|
-
}> {
|
|
223
|
-
const sessionWithVersion = await getSessionWithVersion(sessionToken);
|
|
224
|
-
|
|
225
|
-
if (!sessionWithVersion) {
|
|
226
|
-
return { isFresh: false, versionChanged: true };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const { session, version } = sessionWithVersion;
|
|
230
|
-
const versionChanged = currentVersion ? version !== currentVersion : false;
|
|
231
|
-
|
|
232
|
-
// If version changed, the token might be stale
|
|
233
|
-
// Use normalized field name (idpAccessToken)
|
|
234
|
-
const isFresh = !versionChanged && session.idpAccessToken === currentAccessToken;
|
|
235
|
-
|
|
236
|
-
return {
|
|
237
|
-
isFresh,
|
|
238
|
-
latestAccessToken: session.idpAccessToken || undefined,
|
|
239
|
-
latestVersion: version,
|
|
240
|
-
versionChanged
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Deletes a session from Redis.
|
|
246
|
-
* @param sessionToken The session token to delete.
|
|
247
|
-
*/
|
|
248
|
-
export async function deleteSession(sessionToken: string): Promise<void> {
|
|
249
|
-
if (!sessionToken) {
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const key = getSessionKey(sessionToken);
|
|
253
|
-
const versionKey = getSessionVersionKey(sessionToken);
|
|
254
|
-
await redis.del(key, versionKey);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Deletes a session from EVERY store it can live in (181922): the canonical
|
|
259
|
-
* {slug}:sess:{token} + version key AND the Better Auth secondary
|
|
260
|
-
* ba:{slug}:{token}. Deleting only one lets the survivor resurrect the
|
|
261
|
-
* session — getSession's Path B reads the ba: record when the canonical is
|
|
262
|
-
* gone, so a replayed cookie still authenticated after "logout" (QAPert-
|
|
263
|
-
* NightHawk, exercised on :4702). Every sign-out entry point must use THIS,
|
|
264
|
-
* not deleteSession.
|
|
265
|
-
*
|
|
266
|
-
* The ba: key slug mirrors createBetterAuthInstance's
|
|
267
|
-
* `config.clientSlug || getAppSlug()` — on every current consumer those are
|
|
268
|
-
* the same value (the client-id aliases make it so). If a consumer ever sets
|
|
269
|
-
* them differently, pass appSlug explicitly.
|
|
270
|
-
*/
|
|
271
|
-
export async function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void> {
|
|
272
|
-
if (!sessionToken) {
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
await redis.del(
|
|
276
|
-
getSessionKey(sessionToken),
|
|
277
|
-
getSessionVersionKey(sessionToken),
|
|
278
|
-
getBetterAuthSessionKey(sessionToken, appSlug || getAppSlug()),
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Sets a session directly (for testing or migrations).
|
|
284
|
-
* @param sessionToken The session token.
|
|
285
|
-
* @param data The session data.
|
|
286
|
-
*/
|
|
287
|
-
export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
|
|
288
|
-
const key = getSessionKey(sessionToken);
|
|
289
|
-
const versionKey = getSessionVersionKey(sessionToken);
|
|
290
|
-
await redis.multi()
|
|
291
|
-
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
292
|
-
.incr(versionKey)
|
|
293
|
-
.expire(versionKey, SESSION_TTL)
|
|
294
|
-
.exec();
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Describe a credential-bearing value for a log line WITHOUT the value: its kind only.
|
|
299
|
-
* Deliberately no length or fingerprint either - both narrow the value down.
|
|
300
|
-
*/
|
|
301
|
-
function describeTokenValueForLog(v: unknown): string {
|
|
302
|
-
if (v === undefined) return 'undefined';
|
|
303
|
-
if (v === null) return 'null';
|
|
304
|
-
if (typeof v === 'string') return v.length === 0 ? 'empty string' : 'non-empty string (value withheld)';
|
|
305
|
-
return `${typeof v} (value withheld)`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Updates tokens within an existing session.
|
|
310
|
-
*
|
|
311
|
-
* CALLERS MUST CHECK THE RETURN VALUE (320072). A null return means the session
|
|
312
|
-
* row was absent and EVERY field in `updates` was discarded — it is a dropped
|
|
313
|
-
* write, not a no-op on unchanged data. Ignoring it is how a token refresh came
|
|
314
|
-
* to report success while persisting nothing.
|
|
315
|
-
*
|
|
316
|
-
* @param sessionToken The session token to update.
|
|
317
|
-
* @param updates Partial session data to update.
|
|
318
|
-
* @returns The updated session data, or null if the session was not found — in
|
|
319
|
-
* which case NOTHING WAS WRITTEN.
|
|
320
|
-
*/
|
|
321
|
-
export async function updateSession(
|
|
322
|
-
sessionToken: string,
|
|
323
|
-
updates: Partial<SessionData>
|
|
324
|
-
): Promise<SessionData | null> {
|
|
325
|
-
const key = getSessionKey(sessionToken);
|
|
326
|
-
const versionKey = getSessionVersionKey(sessionToken);
|
|
327
|
-
|
|
328
|
-
// Get current session to merge with updates
|
|
329
|
-
const currentSession = await getSession(sessionToken);
|
|
330
|
-
if (!currentSession) {
|
|
331
|
-
/**
|
|
332
|
-
* 320072: THIS IS A DROPPED WRITE, AND IT USED TO LEAVE NO TRACE.
|
|
333
|
-
*
|
|
334
|
-
* Returning null here is correct — there is no row to merge into — but it
|
|
335
|
-
* was previously done in total silence: no throw, no log. Every caller that
|
|
336
|
-
* ignores the return value therefore believed it had persisted, and the
|
|
337
|
-
* most damaging caller did exactly that (ensure-fresh-access-token).
|
|
338
|
-
*
|
|
339
|
-
* The canonical row can legitimately be absent: better-auth.ts writes the
|
|
340
|
-
* `ba:` record and the canonical row in two steps, and the second is
|
|
341
|
-
* wrapped in a try/catch that only console.warn()s — a deliberate,
|
|
342
|
-
* documented degradation that leaves the BA session correct and this row
|
|
343
|
-
* missing. That decision is fine. What was NOT fine is that the READ side
|
|
344
|
-
* reported success anyway, so nothing ever surfaced it.
|
|
345
|
-
*
|
|
346
|
-
* Logged at error, not warn: the caller's data is gone. Callers that can
|
|
347
|
-
* tolerate a missing row should check the null return — which is now the
|
|
348
|
-
* documented contract — rather than rely on this being quiet.
|
|
349
|
-
*/
|
|
350
|
-
/**
|
|
351
|
-
* DO NOT LOG `key` HERE. `getSessionKey()` (line 21) is
|
|
352
|
-
* `${getSessionPrefix()}${token}` — the prefix plus the WHOLE session
|
|
353
|
-
* token — so logging it writes a live, replayable credential into
|
|
354
|
-
* console.error, and logs travel further than the process that wrote them.
|
|
355
|
-
* The redacting idiom in this file is `substring(0, 8) + '...'`.
|
|
356
|
-
*
|
|
357
|
-
* NOTE ON WHAT THIS COMMENT NO LONGER CLAIMS. It used to say this was the
|
|
358
|
-
* single unredacted departure in the file, and gave a site count. Both were
|
|
359
|
-
* wrong: a RAW refresh token was being emitted at the
|
|
360
|
-
* REFRESH_TOKEN_BEING_CLEARED site below, and the count was stale. QAPert found that
|
|
361
|
-
* leak while reviewing PR 96 — he looked *because* this comment asserted
|
|
362
|
-
* there was nothing to find, which is the worst thing a comment can do.
|
|
363
|
-
* It is fixed in the same commit as this rewrite.
|
|
364
|
-
*
|
|
365
|
-
* So the counts are gone deliberately. A tally in prose decays silently on
|
|
366
|
-
* the next edit and then actively misleads; that decay is the defect being
|
|
367
|
-
* repaired here, and reproducing it in the fix would be absurd. If you need
|
|
368
|
-
* a number, measure it — do not trust this paragraph for one.
|
|
369
|
-
*
|
|
370
|
-
* What replaces the prose is enforcement. The invariant is
|
|
371
|
-
* NO RAW CREDENTIAL IS EVER EMITTED FROM THIS FUNCTION, and it is pinned by
|
|
372
|
-
* executable assertions, not by this text:
|
|
373
|
-
* - Case 4 (session token, this log)
|
|
374
|
-
* - Case 5 (refresh token, the REFRESH_TOKEN_BEING_CLEARED log below)
|
|
375
|
-
* in test/ensure-fresh-persist-failure.test.cjs. Both were proven to fail
|
|
376
|
-
* when their fix is mutated out. Delete a redaction and a test goes red;
|
|
377
|
-
* delete this comment and nothing does. Trust the tests.
|
|
378
|
-
*
|
|
379
|
-
* Caught by QAPert on 320072 and made binding by BAPert (R1043 s1).
|
|
380
|
-
*
|
|
381
|
-
* The prefix is logged SEPARATELY and deliberately, because it is the field
|
|
382
|
-
* that makes this line diagnostic rather than decorative: a "no canonical
|
|
383
|
-
* row" miss has two very different causes, and only the prefix tells them
|
|
384
|
-
* apart —
|
|
385
|
-
* wrong keyspace (slug/prefix disagreement — the 241875 / 213416 defect
|
|
386
|
-
* documented at line 111, where a writer and a reader
|
|
387
|
-
* resolved different slugs and every lookup silently
|
|
388
|
-
* missed), versus
|
|
389
|
-
* absent row (the documented better-auth.ts two-step degradation).
|
|
390
|
-
* The prefix is an app slug, not a secret. Redacting it too would leave a
|
|
391
|
-
* log that says only "something was missing".
|
|
392
|
-
*/
|
|
393
|
-
console.error('[SESSION-STORE] UPDATE_DROPPED_NO_CANONICAL_ROW', {
|
|
394
|
-
keyPrefix: getSessionPrefix(),
|
|
395
|
-
sessionToken: tokenLogId(sessionToken),
|
|
396
|
-
updatedFields: Object.keys(updates),
|
|
397
|
-
note: 'updateSession found no session row; ALL updates were discarded and null was returned',
|
|
398
|
-
});
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// CRITICAL: Track any refresh token changes (check both old and new field names)
|
|
403
|
-
const hadRefreshToken = !!(currentSession.idpRefreshToken || (currentSession as any).refreshToken);
|
|
404
|
-
const willHaveRefreshToken = (updates as any).idpRefreshToken !== undefined
|
|
405
|
-
? !!(updates as any).idpRefreshToken
|
|
406
|
-
: (updates as any).refreshToken !== undefined
|
|
407
|
-
? !!(updates as any).refreshToken
|
|
408
|
-
: hadRefreshToken;
|
|
409
|
-
|
|
410
|
-
if (hadRefreshToken && !willHaveRefreshToken) {
|
|
411
|
-
console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
|
|
412
|
-
sessionToken: tokenLogId(sessionToken),
|
|
413
|
-
userId: currentSession.userId,
|
|
414
|
-
updateKeys: Object.keys(updates),
|
|
415
|
-
// NEVER the value: a refresh token is a long-lived credential and this line ships to the
|
|
416
|
-
// log sink. The old `idpRefreshToken || refreshToken` printed a live legacy token raw when
|
|
417
|
-
// idpRefreshToken was null/"" (which clears) and refreshToken still held one.
|
|
418
|
-
// Test: test/session-store-refresh-token-not-logged.test.cjs
|
|
419
|
-
idpRefreshTokenInUpdate: describeTokenValueForLog((updates as any).idpRefreshToken),
|
|
420
|
-
legacyRefreshTokenInUpdate: describeTokenValueForLog((updates as any).refreshToken),
|
|
421
|
-
refreshTokenClearedReason: (updates as any).refreshTokenClearedReason || 'UNKNOWN',
|
|
422
|
-
stack: new Error().stack
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Merge current session with updates
|
|
427
|
-
const updatedSession = { ...currentSession, ...updates };
|
|
428
|
-
|
|
429
|
-
// Write the entire updated session back to Redis with version increment
|
|
430
|
-
await redis.multi()
|
|
431
|
-
.setex(key, SESSION_TTL, JSON.stringify(updatedSession))
|
|
432
|
-
.incr(versionKey)
|
|
433
|
-
.expire(versionKey, SESSION_TTL)
|
|
434
|
-
.exec();
|
|
435
|
-
|
|
436
|
-
return updatedSession;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Derives the MFA lifetime fields from an IDP access token.
|
|
441
|
-
*
|
|
442
|
-
* *** CARD 339563. THE FLAG AND THE EXPIRY ARE ONE UNIT - WRITE THEM TOGETHER. ***
|
|
443
|
-
*
|
|
444
|
-
* The 2FA-completion writers below used to set `mfaVerified: true` and never
|
|
445
|
-
* write `mfaExpiresAt`, while every consumer reads the expiry as
|
|
446
|
-
* const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
447
|
-
* const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
448
|
-
* The `> 0` guard makes a MISSING expiry unfalsifiable, so `mfaExpired` is
|
|
449
|
-
* forced false forever and the session's MFA state never ages out. The IDP is
|
|
450
|
-
* NOT at fault: IdentityBearerTokenService emits mfa_time + mfa_expires +
|
|
451
|
-
* mfa_validity_hours together inside one `if` block, so the claims arrive as an
|
|
452
|
-
* atomic set or not at all. We were dropping them on the floor.
|
|
453
|
-
*
|
|
454
|
-
* *** THIS RETURNS `{}` WHEN THE TOKEN CARRIES NO mfa_expires, AND THAT IS
|
|
455
|
-
* *** DELIBERATE - DO NOT DEFAULT IT TO `Date.now()` OR TO A CONSTANT. ***
|
|
456
|
-
* The step-up policy omits mfa_time on purpose when the only factor was a
|
|
457
|
-
* primary credential (e.g. passwordless email login): inventing a timestamp
|
|
458
|
-
* because one was missing is forging the evidence. A lane with no claim is a
|
|
459
|
-
* lane where we do not KNOW the expiry, and the honest fix for that case is to
|
|
460
|
-
* make the consumers fail CLOSED on a missing expiry - a separate change, on
|
|
461
|
-
* this same card, which MUST land after this one (the card's option (b)).
|
|
462
|
-
*
|
|
463
|
-
* Mirrors ensure-fresh-access-token.ts and api-handlers/auth/refresh.ts, which
|
|
464
|
-
* already derive these three fields off the token on every exchange (170007
|
|
465
|
-
* rule 2: re-derive from the token, never carry forward).
|
|
466
|
-
*/
|
|
467
|
-
function mfaLifetimeFromAccessToken(accessToken?: string): Partial<SessionData> {
|
|
468
|
-
if (!accessToken) return {};
|
|
469
|
-
|
|
470
|
-
const decoded = decodeIdpAccessToken(accessToken);
|
|
471
|
-
if (!decoded) return {};
|
|
472
|
-
|
|
473
|
-
const fields: Partial<SessionData> = {};
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* *** TEST THE CONVERTED VALUE, NOT THE CLAIM'S PRESENCE. ***
|
|
477
|
-
*
|
|
478
|
-
* This guard previously branched on `claim !== undefined && claim !== null`
|
|
479
|
-
* and then wrote `Number(claim) * 1000`. Those are two different questions,
|
|
480
|
-
* and for a non-numeric claim they disagree: the claim IS present, so the
|
|
481
|
-
* branch is taken, and the value written is NaN. That single NaN defeats both
|
|
482
|
-
* guards in this system at once, in opposite directions:
|
|
483
|
-
*
|
|
484
|
-
* - `mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined` is TRUE
|
|
485
|
-
* for NaN, so R908(e) certifies a ceremony it never observed and GRANTS
|
|
486
|
-
* mfaVerified.
|
|
487
|
-
* - consumers read `mfaExpiresAt || 0`, and NaN is FALSY, so that collapses
|
|
488
|
-
* to 0, `0 > 0` is false, and the session NEVER expires.
|
|
489
|
-
*
|
|
490
|
-
* Net: `mfaVerified: true` that nothing can ever expire — the exact
|
|
491
|
-
* pre-339563 fail-open described at the top of this function, restored
|
|
492
|
-
* through a malformed claim, and silent (neither warning below fires on NaN).
|
|
493
|
-
*
|
|
494
|
-
* So a claim that will not convert is treated as NO CLAIM: the key is OMITTED
|
|
495
|
-
* and R908(e) withholds the grant, identical to the absent-claim lane. Absent
|
|
496
|
-
* and unreadable are the same epistemic state — we do not know the expiry —
|
|
497
|
-
* and they must not produce opposite security outcomes.
|
|
498
|
-
*
|
|
499
|
-
* Omitted, never set-to-undefined: updateSession() merges with
|
|
500
|
-
* `{ ...current, ...updates }`, so a present-but-undefined key would CLOBBER
|
|
501
|
-
* a good stored value. Pinned by test/mfa-expiry-malformed-claim.test.cjs.
|
|
502
|
-
*
|
|
503
|
-
* *** `Number.isFinite` ALONE IS NOT ENOUGH — IT MUST ALSO BE POSITIVE. ***
|
|
504
|
-
* `Number([])` is 0, and 0 is perfectly finite. But the consumer guard reads
|
|
505
|
-
* `mfaExpiresAt || 0`, and 0 is FALSY, so a stored zero is indistinguishable
|
|
506
|
-
* from a missing value and fails open in precisely the same way NaN does —
|
|
507
|
-
* while still passing `!== undefined` and so still earning the R908(e) grant.
|
|
508
|
-
* The honest invariant is not "is it a number" but "can this value ever
|
|
509
|
-
* expire a session", and only a positive one can. A zero/negative
|
|
510
|
-
* `mfa_expires` is meaningless anyway (epoch 1970 or earlier).
|
|
511
|
-
*
|
|
512
|
-
* The `typeof` gate rejects objects and arrays outright rather than letting
|
|
513
|
-
* JS coercion invent a number for them: `[]` -> 0 and `[5]` -> 5 are accidents
|
|
514
|
-
* of coercion, not claims the IDP made.
|
|
515
|
-
*/
|
|
516
|
-
const claimToNumber = (claim: unknown): number | undefined => {
|
|
517
|
-
if (typeof claim !== 'number' && typeof claim !== 'string') return undefined;
|
|
518
|
-
if (claim === '') return undefined;
|
|
519
|
-
const n = Number(claim);
|
|
520
|
-
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
// Claims are Unix SECONDS; SessionData stores milliseconds.
|
|
524
|
-
const mfaExpires = claimToNumber(decoded.mfa_expires);
|
|
525
|
-
if (mfaExpires !== undefined) {
|
|
526
|
-
fields.mfaExpiresAt = mfaExpires * 1000;
|
|
527
|
-
}
|
|
528
|
-
const mfaTime = claimToNumber(decoded.mfa_time);
|
|
529
|
-
if (mfaTime !== undefined) {
|
|
530
|
-
fields.mfaCompletedAt = mfaTime * 1000;
|
|
531
|
-
}
|
|
532
|
-
const mfaValidityHours = claimToNumber(decoded.mfa_validity_hours);
|
|
533
|
-
if (mfaValidityHours !== undefined) {
|
|
534
|
-
fields.mfaValidityHours = mfaValidityHours;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
return fields;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Transitions a session to a MFA-completed state.
|
|
542
|
-
* @param sessionToken The session token to update.
|
|
543
|
-
* @param tokens The new tokens received after MFA completion.
|
|
544
|
-
* @param mfaMethod The MFA method used (email, sms, totp) - required for token refresh.
|
|
545
|
-
* @returns The updated session data.
|
|
546
|
-
*/
|
|
547
|
-
export async function transitionTo2FASession(
|
|
548
|
-
sessionToken: string,
|
|
549
|
-
tokens: {
|
|
550
|
-
accessToken?: string;
|
|
551
|
-
refreshToken?: string;
|
|
552
|
-
accessTokenExpires?: number;
|
|
553
|
-
refreshTokenExpires?: number;
|
|
554
|
-
// Support new field names
|
|
555
|
-
idpAccessToken?: string;
|
|
556
|
-
idpRefreshToken?: string;
|
|
557
|
-
idpAccessTokenExpires?: number;
|
|
558
|
-
idpRefreshTokenExpires?: number;
|
|
559
|
-
},
|
|
560
|
-
mfaMethod?: 'email' | 'sms' | 'totp'
|
|
561
|
-
): Promise<SessionData | null> {
|
|
562
|
-
const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
|
|
563
|
-
|
|
564
|
-
console.log('[transitionTo2FASession] Called with:', {
|
|
565
|
-
sessionToken: tokenLogId(sessionToken),
|
|
566
|
-
mfaMethod,
|
|
567
|
-
hasAccessToken: !!newAccessToken,
|
|
568
|
-
hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
// Extract bearerKeyId from the new access token (IDP may use different key after 2FA)
|
|
572
|
-
let bearerKeyId: string | undefined;
|
|
573
|
-
if (newAccessToken) {
|
|
574
|
-
bearerKeyId = extractKidFromToken(newAccessToken);
|
|
575
|
-
if (bearerKeyId) {
|
|
576
|
-
console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 339563: derive the expiry ONCE - it is both a field to write and the
|
|
581
|
-
// evidence test below. Re-deriving it twice invites the two reads to drift.
|
|
582
|
-
const mfaLifetime = mfaLifetimeFromAccessToken(newAccessToken);
|
|
583
|
-
|
|
584
|
-
// *** R908(e). ABSENCE OF THE EXPIRY IS NOT A COMPLETED CEREMONY. ***
|
|
585
|
-
//
|
|
586
|
-
// `mfaExpiresAt` is the only evidence we hold that a real MFA ceremony took
|
|
587
|
-
// place and when it lapses. When the stepped-up token carries no `mfa_expires`
|
|
588
|
-
// claim we do NOT know that this session is MFA-verified, and R908(e) binds
|
|
589
|
-
// the consumer to the third arm:
|
|
590
|
-
//
|
|
591
|
-
// producers degrade -> consumers RE-CHALLENGE -> nobody infers a
|
|
592
|
-
// negative from a silence.
|
|
593
|
-
//
|
|
594
|
-
// So: never mint `mfaVerified: true` off a token that carries no expiry (that
|
|
595
|
-
// is forging evidence of a ceremony we did not observe - 12188/12390), and
|
|
596
|
-
// never hard-refuse the user either (R486: null is "cannot determine", not
|
|
597
|
-
// "not 2FA-backed"). Leave the session un-verified and set `nextStep:
|
|
598
|
-
// 'mfa_verify'`, which is the field the gate already reads to mean "a usable
|
|
599
|
-
// method exists; challenge them" (SessionModel.ts:136).
|
|
600
|
-
const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
|
|
601
|
-
|
|
602
|
-
// Support both old and new field names in input
|
|
603
|
-
// CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
|
|
604
|
-
// auth.ts session callback reads twoFactorComplete, so we must set it here
|
|
605
|
-
const updates: Partial<SessionData> = {
|
|
606
|
-
idpAccessToken: newAccessToken,
|
|
607
|
-
idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
|
|
608
|
-
idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
|
|
609
|
-
mfaVerified: mfaEvidencePresent,
|
|
610
|
-
twoFactorComplete: mfaEvidencePresent, // Legacy field - required by auth.ts session callback
|
|
611
|
-
mfaMethod: mfaMethod,
|
|
612
|
-
// Update bearerKeyId if extracted from new token
|
|
613
|
-
...(bearerKeyId && { bearerKeyId }),
|
|
614
|
-
// 339563: write the expiry in the SAME update that sets the flag. We already
|
|
615
|
-
// hold the stepped-up token here and already decode its header above.
|
|
616
|
-
...mfaLifetime,
|
|
617
|
-
// R908(e): no evidence -> route to the challenge, do not grant and do not eject.
|
|
618
|
-
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' as const }),
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
if (!mfaEvidencePresent) {
|
|
622
|
-
console.warn(
|
|
623
|
-
'[transitionTo2FASession] R908(e): the stepped-up token carries NO mfa_expires ' +
|
|
624
|
-
'claim, so there is no evidence of an MFA ceremony to record. NOT setting ' +
|
|
625
|
-
'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
|
|
626
|
-
'If this fires for a lane that DID complete a real ceremony, the defect is ' +
|
|
627
|
-
'on the emit side (R908(b)) - do not "fix" it by minting the flag here.',
|
|
628
|
-
{
|
|
629
|
-
sessionToken: tokenLogId(sessionToken),
|
|
630
|
-
mfaMethod,
|
|
631
|
-
hasAccessToken: !!newAccessToken,
|
|
632
|
-
}
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
|
|
637
|
-
if (refreshExpires !== undefined) {
|
|
638
|
-
updates.idpRefreshTokenExpires = refreshExpires;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
console.log('[transitionTo2FASession] Updates to apply:', {
|
|
642
|
-
mfaVerified: updates.mfaVerified,
|
|
643
|
-
twoFactorComplete: (updates as any).twoFactorComplete,
|
|
644
|
-
mfaMethod: updates.mfaMethod,
|
|
645
|
-
hasIdpAccessToken: !!updates.idpAccessToken,
|
|
646
|
-
// 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
|
|
647
|
-
// the passwordless/primary-credential lane (token carried no mfa_expires).
|
|
648
|
-
// Since R908(e) that absence no longer fails OPEN: it withholds mfaVerified
|
|
649
|
-
// and sets nextStep=mfa_verify instead, so log both together - the pair is
|
|
650
|
-
// the whole decision.
|
|
651
|
-
mfaExpiresAt: updates.mfaExpiresAt,
|
|
652
|
-
mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
|
|
653
|
-
nextStep: (updates as any).nextStep,
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
const result = await updateSession(sessionToken, updates);
|
|
657
|
-
|
|
658
|
-
console.log('[transitionTo2FASession] Result:', {
|
|
659
|
-
success: !!result,
|
|
660
|
-
resultMfaVerified: result?.mfaVerified,
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
return result;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Updates IDP tokens and their expiries in an existing session.
|
|
668
|
-
* @param sessionToken The session token to update.
|
|
669
|
-
* @param idpAccessToken The new IDP access token.
|
|
670
|
-
* @param idpRefreshToken The new IDP refresh token.
|
|
671
|
-
* @param idpAccessTokenExpires The access token expiry timestamp.
|
|
672
|
-
* @param idpRefreshTokenExpires The refresh token expiry timestamp (optional).
|
|
673
|
-
* @returns The updated session data.
|
|
674
|
-
*/
|
|
675
|
-
export async function updateTokens(
|
|
676
|
-
sessionToken: string,
|
|
677
|
-
idpAccessToken: string,
|
|
678
|
-
idpRefreshToken: string,
|
|
679
|
-
idpAccessTokenExpires: number,
|
|
680
|
-
idpRefreshTokenExpires?: number
|
|
681
|
-
): Promise<SessionData | null> {
|
|
682
|
-
return updateSession(sessionToken, {
|
|
683
|
-
idpAccessToken,
|
|
684
|
-
idpRefreshToken,
|
|
685
|
-
idpAccessTokenExpires,
|
|
686
|
-
idpRefreshTokenExpires,
|
|
687
|
-
} as Partial<SessionData>);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Marks a session as having completed MFA.
|
|
692
|
-
*
|
|
693
|
-
* *** 339563: PASS `accessToken` - THE STEPPED-UP ONE THE IDP JUST RETURNED. ***
|
|
694
|
-
* Without it this writes a `mfaVerified: true` that no consumer can ever expire
|
|
695
|
-
* (see mfaLifetimeFromAccessToken above). The parameter is OPTIONAL only so
|
|
696
|
-
* that this published package keeps its existing signature for external
|
|
697
|
-
* callers; every in-tree caller supplies it, and omitting it re-opens the
|
|
698
|
-
* fail-open for that call site.
|
|
699
|
-
*
|
|
700
|
-
* It is NOT defaulted to the session's stored token on purpose. Reading it back
|
|
701
|
-
* would work today only because the sole caller happens to run `updateTokens()`
|
|
702
|
-
* first, and depending on undocumented call ORDERING is how this class of bug
|
|
703
|
-
* gets reintroduced. The token is an input, so take it as one.
|
|
704
|
-
*
|
|
705
|
-
* @param sessionToken The session token to update.
|
|
706
|
-
* @param accessToken The access token minted AFTER MFA completion, carrying the
|
|
707
|
-
* mfa_time / mfa_expires / mfa_validity_hours claim set.
|
|
708
|
-
* @returns The updated session data.
|
|
709
|
-
*/
|
|
710
|
-
export async function mark2FAComplete(
|
|
711
|
-
sessionToken: string,
|
|
712
|
-
accessToken?: string
|
|
713
|
-
): Promise<SessionData | null> {
|
|
714
|
-
const mfaLifetime = mfaLifetimeFromAccessToken(accessToken);
|
|
715
|
-
|
|
716
|
-
if (!accessToken) {
|
|
717
|
-
console.warn(
|
|
718
|
-
'[mark2FAComplete] 339563: called with NO access token, so no mfaExpiresAt ' +
|
|
719
|
-
'can be derived. This session\'s MFA state will not expire until its next ' +
|
|
720
|
-
'token refresh repairs it. Pass the stepped-up access token.'
|
|
721
|
-
);
|
|
722
|
-
} else if (mfaLifetime.mfaExpiresAt === undefined) {
|
|
723
|
-
console.warn(
|
|
724
|
-
'[mark2FAComplete] 339563: access token carries NO mfa_expires claim, so ' +
|
|
725
|
-
'this session has no MFA expiry. Expected on a primary-credential lane ' +
|
|
726
|
-
'(e.g. passwordless email); unexpected on an ordinary 2FA completion.'
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// R908(e): identical rule to transitionTo2FASession above - no expiry derived
|
|
731
|
-
// means no observed ceremony, so we re-challenge rather than mint the flag.
|
|
732
|
-
// This path matters more than its sibling: `mark2FAComplete(sessionToken)`
|
|
733
|
-
// with the accessToken omitted used to write a bare `mfaVerified: true` that
|
|
734
|
-
// NO consumer could ever expire, and it is reachable from route handlers that
|
|
735
|
-
// take their tokens from the request body.
|
|
736
|
-
const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
|
|
737
|
-
|
|
738
|
-
if (!mfaEvidencePresent) {
|
|
739
|
-
console.warn(
|
|
740
|
-
'[mark2FAComplete] R908(e): no mfa_expires could be derived, so mfaVerified ' +
|
|
741
|
-
'is NOT being set. Writing nextStep=mfa_verify instead so the consumer ' +
|
|
742
|
-
're-challenges. A caller that needs the grant must pass the stepped-up ' +
|
|
743
|
-
'access token that carries the claim.'
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
return updateSession(sessionToken, {
|
|
748
|
-
mfaVerified: mfaEvidencePresent,
|
|
749
|
-
...mfaLifetime,
|
|
750
|
-
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' as const }),
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* Checks if MFA is complete for a session.
|
|
756
|
-
* @param sessionToken The session token.
|
|
757
|
-
* @returns True if MFA is complete.
|
|
758
|
-
*/
|
|
759
|
-
export async function is2FAComplete(sessionToken: string): Promise<boolean> {
|
|
760
|
-
const session = await getSession(sessionToken);
|
|
761
|
-
return session?.mfaVerified === true;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Gets IDP tokens from a session.
|
|
766
|
-
* @param sessionToken The session token.
|
|
767
|
-
* @returns The tokens or null if session not found.
|
|
768
|
-
*/
|
|
769
|
-
export async function getTokens(sessionToken: string): Promise<{
|
|
770
|
-
accessToken: string;
|
|
771
|
-
refreshToken: string;
|
|
772
|
-
// Also expose new names for clarity
|
|
773
|
-
idpAccessToken: string;
|
|
774
|
-
idpRefreshToken: string;
|
|
775
|
-
} | null> {
|
|
776
|
-
const session = await getSession(sessionToken);
|
|
777
|
-
if (!session || !session.idpAccessToken || !session.idpRefreshToken) {
|
|
778
|
-
return null;
|
|
779
|
-
}
|
|
780
|
-
return {
|
|
781
|
-
// Legacy names for backward compatibility
|
|
782
|
-
accessToken: session.idpAccessToken,
|
|
783
|
-
refreshToken: session.idpRefreshToken,
|
|
784
|
-
// New normalized names
|
|
785
|
-
idpAccessToken: session.idpAccessToken,
|
|
786
|
-
idpRefreshToken: session.idpRefreshToken,
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Refreshes a JWT session (placeholder for compatibility).
|
|
792
|
-
* @param sessionToken The session token.
|
|
793
|
-
* @returns The session data or null.
|
|
794
|
-
*/
|
|
795
|
-
export async function refreshJWTSession(sessionToken: string): Promise<SessionData | null> {
|
|
796
|
-
return getSession(sessionToken);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/**
|
|
800
|
-
* Clears all sessions (for testing only).
|
|
801
|
-
*/
|
|
802
|
-
export async function clearAllSessions(): Promise<void> {
|
|
803
|
-
console.warn('[SESSION-STORE] clearAllSessions called - this should only be used in testing');
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* Lists all sessions (for testing/debugging only).
|
|
808
|
-
* @returns An empty array (placeholder).
|
|
809
|
-
*/
|
|
810
|
-
export async function listAllSessions(): Promise<string[]> {
|
|
811
|
-
console.warn('[SESSION-STORE] listAllSessions called - this should only be used in testing');
|
|
812
|
-
return [];
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// ===============================
|
|
816
|
-
// DISTRIBUTED REFRESH COORDINATION
|
|
817
|
-
// ===============================
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Attempt to acquire a refresh lock for a session
|
|
821
|
-
* Uses Redis SET with NX (Not eXists) for atomic lock acquisition
|
|
822
|
-
*/
|
|
823
|
-
export async function acquireRefreshLock(
|
|
824
|
-
sessionToken: string,
|
|
825
|
-
requestId: string,
|
|
826
|
-
maxWaitMs: number = 5000
|
|
827
|
-
): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
|
|
828
|
-
const lockKey = getRefreshLockKey(sessionToken);
|
|
829
|
-
const acquiredAt = Date.now();
|
|
830
|
-
const lockVersion = Math.floor(Math.random() * 1000000);
|
|
831
|
-
|
|
832
|
-
const lockInfo: RefreshLockInfo = {
|
|
833
|
-
sessionToken,
|
|
834
|
-
acquiredAt,
|
|
835
|
-
acquiredBy: requestId,
|
|
836
|
-
lockVersion
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
try {
|
|
840
|
-
// Try to acquire the lock atomically
|
|
841
|
-
const result = await redis.set(lockKey, JSON.stringify(lockInfo), 'PX', REFRESH_LOCK_TTL * 1000, 'NX');
|
|
842
|
-
|
|
843
|
-
if (result === 'OK') {
|
|
844
|
-
console.debug('[SESSION-STORE] Refresh lock acquired', {
|
|
845
|
-
sessionToken: tokenLogId(sessionToken),
|
|
846
|
-
requestId,
|
|
847
|
-
lockVersion
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
return { acquired: true, lockInfo };
|
|
851
|
-
} else {
|
|
852
|
-
// Lock already exists, check if we should wait
|
|
853
|
-
if (maxWaitMs > 0) {
|
|
854
|
-
console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
|
|
855
|
-
sessionToken: tokenLogId(sessionToken),
|
|
856
|
-
requestId,
|
|
857
|
-
maxWaitMs
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
return await waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs);
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
|
|
864
|
-
sessionToken: tokenLogId(sessionToken),
|
|
865
|
-
requestId
|
|
866
|
-
});
|
|
867
|
-
|
|
868
|
-
return { acquired: false };
|
|
869
|
-
}
|
|
870
|
-
} catch (error) {
|
|
871
|
-
console.error('[SESSION-STORE] Failed to acquire refresh lock', {
|
|
872
|
-
sessionToken: tokenLogId(sessionToken),
|
|
873
|
-
requestId,
|
|
874
|
-
error: error instanceof Error ? error.message : String(error)
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
return { acquired: false };
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* Wait for a refresh lock to be released
|
|
883
|
-
*/
|
|
884
|
-
async function waitForRefreshLockRelease(
|
|
885
|
-
sessionToken: string,
|
|
886
|
-
requestId: string,
|
|
887
|
-
maxWaitMs: number
|
|
888
|
-
): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
|
|
889
|
-
const lockKey = getRefreshLockKey(sessionToken);
|
|
890
|
-
const startTime = Date.now();
|
|
891
|
-
const pollInterval = 100;
|
|
892
|
-
|
|
893
|
-
while (Date.now() - startTime < maxWaitMs) {
|
|
894
|
-
try {
|
|
895
|
-
const lockExists = await redis.exists(lockKey);
|
|
896
|
-
|
|
897
|
-
if (!lockExists) {
|
|
898
|
-
// Lock released - do not reacquire here to avoid double refresh
|
|
899
|
-
return { acquired: false };
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// Wait before next check
|
|
903
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
904
|
-
} catch (error) {
|
|
905
|
-
console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
|
|
906
|
-
sessionToken: tokenLogId(sessionToken),
|
|
907
|
-
requestId,
|
|
908
|
-
error: error instanceof Error ? error.message : String(error)
|
|
909
|
-
});
|
|
910
|
-
break;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
|
|
915
|
-
sessionToken: tokenLogId(sessionToken),
|
|
916
|
-
requestId,
|
|
917
|
-
waitedMs: Date.now() - startTime
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
return { acquired: false };
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
/**
|
|
924
|
-
* Release a refresh lock
|
|
925
|
-
* Uses Lua script to ensure atomic validation and release
|
|
926
|
-
*/
|
|
927
|
-
export async function releaseRefreshLock(
|
|
928
|
-
sessionToken: string,
|
|
929
|
-
requestId: string,
|
|
930
|
-
lockVersion?: number
|
|
931
|
-
): Promise<boolean> {
|
|
932
|
-
const lockKey = getRefreshLockKey(sessionToken);
|
|
933
|
-
|
|
934
|
-
try {
|
|
935
|
-
// Lua script for atomic lock validation and release
|
|
936
|
-
const luaScript = `
|
|
937
|
-
local lockKey = KEYS[1]
|
|
938
|
-
local expectedRequestId = ARGV[1]
|
|
939
|
-
local expectedVersion = ARGV[2]
|
|
940
|
-
|
|
941
|
-
local lockData = redis.call('GET', lockKey)
|
|
942
|
-
if not lockData then
|
|
943
|
-
return 0 -- Lock doesn't exist
|
|
944
|
-
end
|
|
945
|
-
|
|
946
|
-
local lockInfo = cjson.decode(lockData)
|
|
947
|
-
if lockInfo.acquiredBy == expectedRequestId then
|
|
948
|
-
if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
|
|
949
|
-
redis.call('DEL', lockKey)
|
|
950
|
-
return 1 -- Successfully released
|
|
951
|
-
else
|
|
952
|
-
return -2 -- Version mismatch
|
|
953
|
-
end
|
|
954
|
-
else
|
|
955
|
-
return -1 -- Wrong owner
|
|
956
|
-
end
|
|
957
|
-
`;
|
|
958
|
-
|
|
959
|
-
const result = await redis.eval(
|
|
960
|
-
luaScript,
|
|
961
|
-
1,
|
|
962
|
-
lockKey,
|
|
963
|
-
requestId,
|
|
964
|
-
lockVersion ? lockVersion.toString() : ''
|
|
965
|
-
) as number;
|
|
966
|
-
|
|
967
|
-
if (result === 1) {
|
|
968
|
-
console.debug('[SESSION-STORE] Refresh lock released successfully', {
|
|
969
|
-
sessionToken: tokenLogId(sessionToken),
|
|
970
|
-
requestId,
|
|
971
|
-
lockVersion
|
|
972
|
-
});
|
|
973
|
-
return true;
|
|
974
|
-
} else if (result === 0) {
|
|
975
|
-
console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
|
|
976
|
-
sessionToken: tokenLogId(sessionToken),
|
|
977
|
-
requestId
|
|
978
|
-
});
|
|
979
|
-
return false;
|
|
980
|
-
} else if (result === -1) {
|
|
981
|
-
console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
|
|
982
|
-
sessionToken: tokenLogId(sessionToken),
|
|
983
|
-
requestId
|
|
984
|
-
});
|
|
985
|
-
return false;
|
|
986
|
-
} else if (result === -2) {
|
|
987
|
-
console.error('[SESSION-STORE] Lock version mismatch during release', {
|
|
988
|
-
sessionToken: tokenLogId(sessionToken),
|
|
989
|
-
requestId,
|
|
990
|
-
lockVersion
|
|
991
|
-
});
|
|
992
|
-
return false;
|
|
993
|
-
} else {
|
|
994
|
-
console.error('[SESSION-STORE] Unexpected result from lock release script', {
|
|
995
|
-
sessionToken: tokenLogId(sessionToken),
|
|
996
|
-
requestId,
|
|
997
|
-
result
|
|
998
|
-
});
|
|
999
|
-
return false;
|
|
1000
|
-
}
|
|
1001
|
-
} catch (error) {
|
|
1002
|
-
console.error('[SESSION-STORE] Failed to release refresh lock', {
|
|
1003
|
-
sessionToken: tokenLogId(sessionToken),
|
|
1004
|
-
requestId,
|
|
1005
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
return false;
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
/**
|
|
1013
|
-
* Check if a refresh lock exists for a session
|
|
1014
|
-
*/
|
|
1015
|
-
export async function checkRefreshLock(sessionToken: string): Promise<RefreshLockInfo | null> {
|
|
1016
|
-
const lockKey = getRefreshLockKey(sessionToken);
|
|
1017
|
-
|
|
1018
|
-
try {
|
|
1019
|
-
const lockData = await redis.get(lockKey);
|
|
1020
|
-
|
|
1021
|
-
if (!lockData) {
|
|
1022
|
-
return null;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
return JSON.parse(lockData) as RefreshLockInfo;
|
|
1026
|
-
} catch (error) {
|
|
1027
|
-
console.error('[SESSION-STORE] Failed to check refresh lock', {
|
|
1028
|
-
sessionToken: tokenLogId(sessionToken),
|
|
1029
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1030
|
-
});
|
|
1031
|
-
|
|
1032
|
-
return null;
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/**
|
|
1037
|
-
* Simple check if a refresh is currently in progress for a session
|
|
1038
|
-
*/
|
|
1039
|
-
export async function isRefreshInProgress(sessionToken: string): Promise<boolean> {
|
|
1040
|
-
const lock = await checkRefreshLock(sessionToken);
|
|
1041
|
-
return lock !== null;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
/**
|
|
1045
|
-
* Force cleanup of expired or orphaned refresh locks
|
|
1046
|
-
*/
|
|
1047
|
-
export async function cleanupRefreshLocks(): Promise<number> {
|
|
1048
|
-
console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
|
|
1049
|
-
return 0;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* PAY-2136 DIAGNOSTIC (BAPert 70873): which of the session keys EXIST for a token, right now.
|
|
1054
|
-
*
|
|
1055
|
-
* Names/presence only - this returns booleans and the key PREFIX (an app slug, not a secret), never a value and
|
|
1056
|
-
* never any part of the token. It exists so a NO_SESSION can be read for what it was: canonical row gone with the
|
|
1057
|
-
* ba: record alive (the :330 asymmetry), both gone (expired/deleted), or the store could not be asked ('error').
|
|
1058
|
-
*
|
|
1059
|
-
* EXISTS, not GET: it cannot parse, cannot throw on a malformed blob, and cannot return a credential. Each key is
|
|
1060
|
-
* asked separately so one timeout does not hide the other two. Never throws.
|
|
1061
|
-
*/
|
|
1062
|
-
export type KeyPresence = boolean | 'error';
|
|
1063
|
-
export async function describeSessionKeyPresence(
|
|
1064
|
-
sessionToken: string
|
|
1065
|
-
): Promise<{ prefix: string; canonical: KeyPresence; ba: KeyPresence; ver: KeyPresence }> {
|
|
1066
|
-
const ask = async (key: string): Promise<KeyPresence> => {
|
|
1067
|
-
try {
|
|
1068
|
-
return (await redis.exists(key)) > 0;
|
|
1069
|
-
} catch {
|
|
1070
|
-
return 'error';
|
|
1071
|
-
}
|
|
1072
|
-
};
|
|
1073
|
-
let prefix = '';
|
|
1074
|
-
try {
|
|
1075
|
-
prefix = getSessionPrefix();
|
|
1076
|
-
} catch {
|
|
1077
|
-
prefix = '';
|
|
1078
|
-
}
|
|
1079
|
-
if (!sessionToken) return { prefix, canonical: false, ba: false, ver: false };
|
|
1080
|
-
let canonicalKey = '';
|
|
1081
|
-
let baKey = '';
|
|
1082
|
-
let verKey = '';
|
|
1083
|
-
try {
|
|
1084
|
-
canonicalKey = getSessionKey(sessionToken);
|
|
1085
|
-
baKey = getBetterAuthSessionKey(sessionToken, getAppSlug());
|
|
1086
|
-
verKey = getSessionVersionKey(sessionToken);
|
|
1087
|
-
} catch {
|
|
1088
|
-
return { prefix, canonical: 'error', ba: 'error', ver: 'error' };
|
|
1089
|
-
}
|
|
1090
|
-
const [canonical, ba, ver] = await Promise.all([ask(canonicalKey), ask(baKey), ask(verKey)]);
|
|
1091
|
-
return { prefix, canonical, ba, ver };
|
|
1092
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Session Store for `@payez/next-mvp` using ioredis
|
|
3
|
+
*
|
|
4
|
+
* This module provides a Redis-backed session store that is compatible with the
|
|
5
|
+
* `ioredis` client. It handles the creation, retrieval, and deletion of
|
|
6
|
+
* session data, which is the single source of truth for authentication.
|
|
7
|
+
*
|
|
8
|
+
* Includes advanced distributed refresh coordination with version control.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import redis from './redis';
|
|
12
|
+
import { randomBytes } from 'crypto';
|
|
13
|
+
import { SessionData } from '../models/SessionModel';
|
|
14
|
+
import { getSessionPrefix, getRefreshLockPrefix, getAppSlug } from './app-slug';
|
|
15
|
+
import { extractKidFromToken, decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
16
|
+
import { tokenLogId } from './token-log-id';
|
|
17
|
+
|
|
18
|
+
// Re-export SessionData for consumers
|
|
19
|
+
export type { SessionData } from '../models/SessionModel';
|
|
20
|
+
|
|
21
|
+
// Use app-slug prefixes for multi-app isolation
|
|
22
|
+
const getSessionKey = (token: string) => `${getSessionPrefix()}${token}`;
|
|
23
|
+
const getRefreshLockKey = (token: string) => `${getRefreshLockPrefix()}${token}`;
|
|
24
|
+
const getSessionVersionKey = (token: string) => `${getSessionPrefix()}ver:${token}`;
|
|
25
|
+
|
|
26
|
+
// Better Auth uses a different key format: ba:{appSlug}:{token}
|
|
27
|
+
const getBetterAuthSessionKey = (token: string, appSlug?: string) => `ba:${appSlug || 'app'}:${token}`;
|
|
28
|
+
|
|
29
|
+
const REFRESH_LOCK_TTL = 60; // 60 seconds
|
|
30
|
+
// 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
|
|
31
|
+
// of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
|
|
32
|
+
// two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
|
|
33
|
+
export const SESSION_TTL = 3 * 24 * 60 * 60;
|
|
34
|
+
|
|
35
|
+
export interface RefreshLockInfo {
|
|
36
|
+
sessionToken: string;
|
|
37
|
+
acquiredAt: number;
|
|
38
|
+
acquiredBy: string; // request ID or process identifier
|
|
39
|
+
lockVersion: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generates a new session token.
|
|
44
|
+
* @returns A new session token string.
|
|
45
|
+
*/
|
|
46
|
+
export function generateSessionToken(): string {
|
|
47
|
+
return randomBytes(32).toString('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a new session in Redis.
|
|
52
|
+
*
|
|
53
|
+
* @param data The session data to store.
|
|
54
|
+
* @returns The generated session token (redisSessionId).
|
|
55
|
+
*/
|
|
56
|
+
export async function createSession(data: SessionData): Promise<string> {
|
|
57
|
+
const sessionToken = randomBytes(32).toString('hex');
|
|
58
|
+
const key = getSessionKey(sessionToken);
|
|
59
|
+
const versionKey = getSessionVersionKey(sessionToken);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await redis.multi()
|
|
63
|
+
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
64
|
+
.setex(versionKey, SESSION_TTL, '1')
|
|
65
|
+
.exec();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('[SESSION-STORE] Failed to create session:', error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return sessionToken;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Retrieves a session from Redis.
|
|
76
|
+
*
|
|
77
|
+
* @param sessionToken The session token (redisSessionId) to look up.
|
|
78
|
+
* @returns The session data, or null if not found.
|
|
79
|
+
*/
|
|
80
|
+
export async function getSession(sessionToken: string): Promise<SessionData | null> {
|
|
81
|
+
if (!sessionToken) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const key = getSessionKey(sessionToken);
|
|
85
|
+
const json = await redis.get(key);
|
|
86
|
+
if (!json) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(json) as SessionData;
|
|
91
|
+
} catch {
|
|
92
|
+
console.error('[SESSION-STORE] Failed to parse session data');
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Retrieves a Better Auth session from Redis.
|
|
99
|
+
* Better Auth uses key format: ba:{appSlug}:{token}
|
|
100
|
+
*
|
|
101
|
+
* @param sessionToken The session token to look up.
|
|
102
|
+
* @param appSlug The app slug. Defaults to getAppSlug() - the SAME source Better Auth
|
|
103
|
+
* writes under - so reader and writer cannot disagree. Pass explicitly
|
|
104
|
+
* only if a consumer sets createBetterAuthInstance's clientSlug to
|
|
105
|
+
* something other than getAppSlug().
|
|
106
|
+
* @returns The session data, or null if not found.
|
|
107
|
+
*/
|
|
108
|
+
export async function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null> {
|
|
109
|
+
if (!sessionToken) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
// 241875 / 213416 (partially-applied fix, completed here): this line used to be
|
|
113
|
+
// appSlug || process.env.CLIENT_ID || 'idealvibe_online'
|
|
114
|
+
// which is a THIRD slug resolution, disagreeing with the two canonical ones in
|
|
115
|
+
// this same file - getSessionKey() uses getSessionPrefix() (i.e. getAppSlug()),
|
|
116
|
+
// and deleteSessionEverywhere() uses `appSlug || getAppSlug()`.
|
|
117
|
+
//
|
|
118
|
+
// WRITER vs READER, same keyspace, different answers. Better Auth WRITES
|
|
119
|
+
// ba:{getAppSlug()}:{token} - deleteSessionEverywhere's own docstring records
|
|
120
|
+
// that it mirrors createBetterAuthInstance's `config.clientSlug || getAppSlug()`.
|
|
121
|
+
// This function READ ba:{CLIENT_ID or 'idealvibe_online'}:{token}.
|
|
122
|
+
//
|
|
123
|
+
// MEASURED IN PRODUCTION (keelauth-portal-config, 2026-08-24): APP_SLUG is
|
|
124
|
+
// declared and CLIENT_ID is NOT. So the old chain fell through to the literal and
|
|
125
|
+
// every one of this function's 14 call sites looked under a key Better Auth never
|
|
126
|
+
// writes. It could not match, and it returned null silently rather than failing -
|
|
127
|
+
// so the BA-session fallback documented in 4.1.3 (viability falling back to BA
|
|
128
|
+
// Redis when the canonical store misses) has been a safety net with a hole in it.
|
|
129
|
+
//
|
|
130
|
+
// 213416 fixed this exact writer/reader split at deleteSessionEverywhere and did
|
|
131
|
+
// not reach here. Using getAppSlug() makes the two agree by construction.
|
|
132
|
+
const slug = appSlug || getAppSlug();
|
|
133
|
+
const key = getBetterAuthSessionKey(sessionToken, slug);
|
|
134
|
+
const json = await redis.get(key);
|
|
135
|
+
if (!json) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const data = JSON.parse(json);
|
|
140
|
+
// Better Auth stores the session differently - extract user data
|
|
141
|
+
if (data.user) {
|
|
142
|
+
return {
|
|
143
|
+
userId: data.user.id || data.user.email,
|
|
144
|
+
email: data.user.email,
|
|
145
|
+
name: data.user.name,
|
|
146
|
+
image: data.user.image,
|
|
147
|
+
idpAccessToken: data.idpTokens?.idpAccessToken,
|
|
148
|
+
idpRefreshToken: data.idpTokens?.idpRefreshToken,
|
|
149
|
+
idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
|
|
150
|
+
mfaVerified: data.idpTokens?.mfaVerified ?? false,
|
|
151
|
+
// R807 (362550): the `?? false` above is CORRECT and stays - fail-closed for
|
|
152
|
+
// every security consumer. This records the bit it destroys, for viability
|
|
153
|
+
// only. Note `!== undefined`, not a truthiness test: an explicit `false` IS
|
|
154
|
+
// a signal, and is precisely the case viability needs to see.
|
|
155
|
+
mfaSignalPresent: data.idpTokens?.mfaVerified !== undefined,
|
|
156
|
+
// Cards 141688/141741 — the IDP's routing decision, carried not derived.
|
|
157
|
+
// `null` is a real value here (IDP said nothing is outstanding), so it
|
|
158
|
+
// must stay distinguishable from `undefined` (we never captured it —
|
|
159
|
+
// true of every session minted before this change). No `?? null`:
|
|
160
|
+
// defaulting would turn "unknown" into "fully authenticated" and
|
|
161
|
+
// fail open on the existing session population.
|
|
162
|
+
nextStep: data.idpTokens?.nextStep,
|
|
163
|
+
roles: data.idpTokens?.roles || [],
|
|
164
|
+
idpClientId: data.idpTokens?.idpClientId ?? data.idpTokens?.clientId ?? data.idpClientId,
|
|
165
|
+
merchantId: data.idpTokens?.merchantId ?? data.merchantId,
|
|
166
|
+
} as SessionData;
|
|
167
|
+
}
|
|
168
|
+
return data as SessionData;
|
|
169
|
+
} catch {
|
|
170
|
+
console.error('[SESSION-STORE] Failed to parse Better Auth session data');
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refresh session TTL without reading/writing data (sliding window expiry).
|
|
177
|
+
*/
|
|
178
|
+
export async function touchSession(token: string): Promise<void> {
|
|
179
|
+
const key = getSessionKey(token);
|
|
180
|
+
await redis.expire(key, SESSION_TTL);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Retrieves a session along with a version identifier for optimistic locking.
|
|
185
|
+
* @param sessionToken The session token to look up.
|
|
186
|
+
* @returns An object with session and version, or null if not found.
|
|
187
|
+
*/
|
|
188
|
+
export async function getSessionWithVersion(sessionToken: string): Promise<{ session: SessionData; version: string } | null> {
|
|
189
|
+
const session = await getSession(sessionToken);
|
|
190
|
+
if (!session) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const versionKey = getSessionVersionKey(sessionToken);
|
|
195
|
+
const version = await redis.get(versionKey);
|
|
196
|
+
|
|
197
|
+
if (!version) {
|
|
198
|
+
// Session exists but version key missing - use idpAccessTokenExpires as fallback
|
|
199
|
+
const fallbackVersion = String(session.idpAccessTokenExpires || Date.now());
|
|
200
|
+
return { session, version: fallbackVersion };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { session, version };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Checks if the access token in a session is still fresh (not expired).
|
|
208
|
+
* @param sessionToken The session token to check.
|
|
209
|
+
* @param currentAccessToken The current access token to compare.
|
|
210
|
+
* @param currentVersion Optional version to check for changes.
|
|
211
|
+
* @returns Object with freshness status and latest token info.
|
|
212
|
+
*/
|
|
213
|
+
export async function isAccessTokenFresh(
|
|
214
|
+
sessionToken: string,
|
|
215
|
+
currentAccessToken: string,
|
|
216
|
+
currentVersion?: string
|
|
217
|
+
): Promise<{
|
|
218
|
+
isFresh: boolean;
|
|
219
|
+
latestAccessToken?: string;
|
|
220
|
+
latestVersion?: string;
|
|
221
|
+
versionChanged: boolean;
|
|
222
|
+
}> {
|
|
223
|
+
const sessionWithVersion = await getSessionWithVersion(sessionToken);
|
|
224
|
+
|
|
225
|
+
if (!sessionWithVersion) {
|
|
226
|
+
return { isFresh: false, versionChanged: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { session, version } = sessionWithVersion;
|
|
230
|
+
const versionChanged = currentVersion ? version !== currentVersion : false;
|
|
231
|
+
|
|
232
|
+
// If version changed, the token might be stale
|
|
233
|
+
// Use normalized field name (idpAccessToken)
|
|
234
|
+
const isFresh = !versionChanged && session.idpAccessToken === currentAccessToken;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
isFresh,
|
|
238
|
+
latestAccessToken: session.idpAccessToken || undefined,
|
|
239
|
+
latestVersion: version,
|
|
240
|
+
versionChanged
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Deletes a session from Redis.
|
|
246
|
+
* @param sessionToken The session token to delete.
|
|
247
|
+
*/
|
|
248
|
+
export async function deleteSession(sessionToken: string): Promise<void> {
|
|
249
|
+
if (!sessionToken) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const key = getSessionKey(sessionToken);
|
|
253
|
+
const versionKey = getSessionVersionKey(sessionToken);
|
|
254
|
+
await redis.del(key, versionKey);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Deletes a session from EVERY store it can live in (181922): the canonical
|
|
259
|
+
* {slug}:sess:{token} + version key AND the Better Auth secondary
|
|
260
|
+
* ba:{slug}:{token}. Deleting only one lets the survivor resurrect the
|
|
261
|
+
* session — getSession's Path B reads the ba: record when the canonical is
|
|
262
|
+
* gone, so a replayed cookie still authenticated after "logout" (QAPert-
|
|
263
|
+
* NightHawk, exercised on :4702). Every sign-out entry point must use THIS,
|
|
264
|
+
* not deleteSession.
|
|
265
|
+
*
|
|
266
|
+
* The ba: key slug mirrors createBetterAuthInstance's
|
|
267
|
+
* `config.clientSlug || getAppSlug()` — on every current consumer those are
|
|
268
|
+
* the same value (the client-id aliases make it so). If a consumer ever sets
|
|
269
|
+
* them differently, pass appSlug explicitly.
|
|
270
|
+
*/
|
|
271
|
+
export async function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void> {
|
|
272
|
+
if (!sessionToken) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await redis.del(
|
|
276
|
+
getSessionKey(sessionToken),
|
|
277
|
+
getSessionVersionKey(sessionToken),
|
|
278
|
+
getBetterAuthSessionKey(sessionToken, appSlug || getAppSlug()),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Sets a session directly (for testing or migrations).
|
|
284
|
+
* @param sessionToken The session token.
|
|
285
|
+
* @param data The session data.
|
|
286
|
+
*/
|
|
287
|
+
export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
|
|
288
|
+
const key = getSessionKey(sessionToken);
|
|
289
|
+
const versionKey = getSessionVersionKey(sessionToken);
|
|
290
|
+
await redis.multi()
|
|
291
|
+
.setex(key, SESSION_TTL, JSON.stringify(data))
|
|
292
|
+
.incr(versionKey)
|
|
293
|
+
.expire(versionKey, SESSION_TTL)
|
|
294
|
+
.exec();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Describe a credential-bearing value for a log line WITHOUT the value: its kind only.
|
|
299
|
+
* Deliberately no length or fingerprint either - both narrow the value down.
|
|
300
|
+
*/
|
|
301
|
+
function describeTokenValueForLog(v: unknown): string {
|
|
302
|
+
if (v === undefined) return 'undefined';
|
|
303
|
+
if (v === null) return 'null';
|
|
304
|
+
if (typeof v === 'string') return v.length === 0 ? 'empty string' : 'non-empty string (value withheld)';
|
|
305
|
+
return `${typeof v} (value withheld)`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Updates tokens within an existing session.
|
|
310
|
+
*
|
|
311
|
+
* CALLERS MUST CHECK THE RETURN VALUE (320072). A null return means the session
|
|
312
|
+
* row was absent and EVERY field in `updates` was discarded — it is a dropped
|
|
313
|
+
* write, not a no-op on unchanged data. Ignoring it is how a token refresh came
|
|
314
|
+
* to report success while persisting nothing.
|
|
315
|
+
*
|
|
316
|
+
* @param sessionToken The session token to update.
|
|
317
|
+
* @param updates Partial session data to update.
|
|
318
|
+
* @returns The updated session data, or null if the session was not found — in
|
|
319
|
+
* which case NOTHING WAS WRITTEN.
|
|
320
|
+
*/
|
|
321
|
+
export async function updateSession(
|
|
322
|
+
sessionToken: string,
|
|
323
|
+
updates: Partial<SessionData>
|
|
324
|
+
): Promise<SessionData | null> {
|
|
325
|
+
const key = getSessionKey(sessionToken);
|
|
326
|
+
const versionKey = getSessionVersionKey(sessionToken);
|
|
327
|
+
|
|
328
|
+
// Get current session to merge with updates
|
|
329
|
+
const currentSession = await getSession(sessionToken);
|
|
330
|
+
if (!currentSession) {
|
|
331
|
+
/**
|
|
332
|
+
* 320072: THIS IS A DROPPED WRITE, AND IT USED TO LEAVE NO TRACE.
|
|
333
|
+
*
|
|
334
|
+
* Returning null here is correct — there is no row to merge into — but it
|
|
335
|
+
* was previously done in total silence: no throw, no log. Every caller that
|
|
336
|
+
* ignores the return value therefore believed it had persisted, and the
|
|
337
|
+
* most damaging caller did exactly that (ensure-fresh-access-token).
|
|
338
|
+
*
|
|
339
|
+
* The canonical row can legitimately be absent: better-auth.ts writes the
|
|
340
|
+
* `ba:` record and the canonical row in two steps, and the second is
|
|
341
|
+
* wrapped in a try/catch that only console.warn()s — a deliberate,
|
|
342
|
+
* documented degradation that leaves the BA session correct and this row
|
|
343
|
+
* missing. That decision is fine. What was NOT fine is that the READ side
|
|
344
|
+
* reported success anyway, so nothing ever surfaced it.
|
|
345
|
+
*
|
|
346
|
+
* Logged at error, not warn: the caller's data is gone. Callers that can
|
|
347
|
+
* tolerate a missing row should check the null return — which is now the
|
|
348
|
+
* documented contract — rather than rely on this being quiet.
|
|
349
|
+
*/
|
|
350
|
+
/**
|
|
351
|
+
* DO NOT LOG `key` HERE. `getSessionKey()` (line 21) is
|
|
352
|
+
* `${getSessionPrefix()}${token}` — the prefix plus the WHOLE session
|
|
353
|
+
* token — so logging it writes a live, replayable credential into
|
|
354
|
+
* console.error, and logs travel further than the process that wrote them.
|
|
355
|
+
* The redacting idiom in this file is `substring(0, 8) + '...'`.
|
|
356
|
+
*
|
|
357
|
+
* NOTE ON WHAT THIS COMMENT NO LONGER CLAIMS. It used to say this was the
|
|
358
|
+
* single unredacted departure in the file, and gave a site count. Both were
|
|
359
|
+
* wrong: a RAW refresh token was being emitted at the
|
|
360
|
+
* REFRESH_TOKEN_BEING_CLEARED site below, and the count was stale. QAPert found that
|
|
361
|
+
* leak while reviewing PR 96 — he looked *because* this comment asserted
|
|
362
|
+
* there was nothing to find, which is the worst thing a comment can do.
|
|
363
|
+
* It is fixed in the same commit as this rewrite.
|
|
364
|
+
*
|
|
365
|
+
* So the counts are gone deliberately. A tally in prose decays silently on
|
|
366
|
+
* the next edit and then actively misleads; that decay is the defect being
|
|
367
|
+
* repaired here, and reproducing it in the fix would be absurd. If you need
|
|
368
|
+
* a number, measure it — do not trust this paragraph for one.
|
|
369
|
+
*
|
|
370
|
+
* What replaces the prose is enforcement. The invariant is
|
|
371
|
+
* NO RAW CREDENTIAL IS EVER EMITTED FROM THIS FUNCTION, and it is pinned by
|
|
372
|
+
* executable assertions, not by this text:
|
|
373
|
+
* - Case 4 (session token, this log)
|
|
374
|
+
* - Case 5 (refresh token, the REFRESH_TOKEN_BEING_CLEARED log below)
|
|
375
|
+
* in test/ensure-fresh-persist-failure.test.cjs. Both were proven to fail
|
|
376
|
+
* when their fix is mutated out. Delete a redaction and a test goes red;
|
|
377
|
+
* delete this comment and nothing does. Trust the tests.
|
|
378
|
+
*
|
|
379
|
+
* Caught by QAPert on 320072 and made binding by BAPert (R1043 s1).
|
|
380
|
+
*
|
|
381
|
+
* The prefix is logged SEPARATELY and deliberately, because it is the field
|
|
382
|
+
* that makes this line diagnostic rather than decorative: a "no canonical
|
|
383
|
+
* row" miss has two very different causes, and only the prefix tells them
|
|
384
|
+
* apart —
|
|
385
|
+
* wrong keyspace (slug/prefix disagreement — the 241875 / 213416 defect
|
|
386
|
+
* documented at line 111, where a writer and a reader
|
|
387
|
+
* resolved different slugs and every lookup silently
|
|
388
|
+
* missed), versus
|
|
389
|
+
* absent row (the documented better-auth.ts two-step degradation).
|
|
390
|
+
* The prefix is an app slug, not a secret. Redacting it too would leave a
|
|
391
|
+
* log that says only "something was missing".
|
|
392
|
+
*/
|
|
393
|
+
console.error('[SESSION-STORE] UPDATE_DROPPED_NO_CANONICAL_ROW', {
|
|
394
|
+
keyPrefix: getSessionPrefix(),
|
|
395
|
+
sessionToken: tokenLogId(sessionToken),
|
|
396
|
+
updatedFields: Object.keys(updates),
|
|
397
|
+
note: 'updateSession found no session row; ALL updates were discarded and null was returned',
|
|
398
|
+
});
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// CRITICAL: Track any refresh token changes (check both old and new field names)
|
|
403
|
+
const hadRefreshToken = !!(currentSession.idpRefreshToken || (currentSession as any).refreshToken);
|
|
404
|
+
const willHaveRefreshToken = (updates as any).idpRefreshToken !== undefined
|
|
405
|
+
? !!(updates as any).idpRefreshToken
|
|
406
|
+
: (updates as any).refreshToken !== undefined
|
|
407
|
+
? !!(updates as any).refreshToken
|
|
408
|
+
: hadRefreshToken;
|
|
409
|
+
|
|
410
|
+
if (hadRefreshToken && !willHaveRefreshToken) {
|
|
411
|
+
console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
|
|
412
|
+
sessionToken: tokenLogId(sessionToken),
|
|
413
|
+
userId: currentSession.userId,
|
|
414
|
+
updateKeys: Object.keys(updates),
|
|
415
|
+
// NEVER the value: a refresh token is a long-lived credential and this line ships to the
|
|
416
|
+
// log sink. The old `idpRefreshToken || refreshToken` printed a live legacy token raw when
|
|
417
|
+
// idpRefreshToken was null/"" (which clears) and refreshToken still held one.
|
|
418
|
+
// Test: test/session-store-refresh-token-not-logged.test.cjs
|
|
419
|
+
idpRefreshTokenInUpdate: describeTokenValueForLog((updates as any).idpRefreshToken),
|
|
420
|
+
legacyRefreshTokenInUpdate: describeTokenValueForLog((updates as any).refreshToken),
|
|
421
|
+
refreshTokenClearedReason: (updates as any).refreshTokenClearedReason || 'UNKNOWN',
|
|
422
|
+
stack: new Error().stack
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Merge current session with updates
|
|
427
|
+
const updatedSession = { ...currentSession, ...updates };
|
|
428
|
+
|
|
429
|
+
// Write the entire updated session back to Redis with version increment
|
|
430
|
+
await redis.multi()
|
|
431
|
+
.setex(key, SESSION_TTL, JSON.stringify(updatedSession))
|
|
432
|
+
.incr(versionKey)
|
|
433
|
+
.expire(versionKey, SESSION_TTL)
|
|
434
|
+
.exec();
|
|
435
|
+
|
|
436
|
+
return updatedSession;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Derives the MFA lifetime fields from an IDP access token.
|
|
441
|
+
*
|
|
442
|
+
* *** CARD 339563. THE FLAG AND THE EXPIRY ARE ONE UNIT - WRITE THEM TOGETHER. ***
|
|
443
|
+
*
|
|
444
|
+
* The 2FA-completion writers below used to set `mfaVerified: true` and never
|
|
445
|
+
* write `mfaExpiresAt`, while every consumer reads the expiry as
|
|
446
|
+
* const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
447
|
+
* const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
448
|
+
* The `> 0` guard makes a MISSING expiry unfalsifiable, so `mfaExpired` is
|
|
449
|
+
* forced false forever and the session's MFA state never ages out. The IDP is
|
|
450
|
+
* NOT at fault: IdentityBearerTokenService emits mfa_time + mfa_expires +
|
|
451
|
+
* mfa_validity_hours together inside one `if` block, so the claims arrive as an
|
|
452
|
+
* atomic set or not at all. We were dropping them on the floor.
|
|
453
|
+
*
|
|
454
|
+
* *** THIS RETURNS `{}` WHEN THE TOKEN CARRIES NO mfa_expires, AND THAT IS
|
|
455
|
+
* *** DELIBERATE - DO NOT DEFAULT IT TO `Date.now()` OR TO A CONSTANT. ***
|
|
456
|
+
* The step-up policy omits mfa_time on purpose when the only factor was a
|
|
457
|
+
* primary credential (e.g. passwordless email login): inventing a timestamp
|
|
458
|
+
* because one was missing is forging the evidence. A lane with no claim is a
|
|
459
|
+
* lane where we do not KNOW the expiry, and the honest fix for that case is to
|
|
460
|
+
* make the consumers fail CLOSED on a missing expiry - a separate change, on
|
|
461
|
+
* this same card, which MUST land after this one (the card's option (b)).
|
|
462
|
+
*
|
|
463
|
+
* Mirrors ensure-fresh-access-token.ts and api-handlers/auth/refresh.ts, which
|
|
464
|
+
* already derive these three fields off the token on every exchange (170007
|
|
465
|
+
* rule 2: re-derive from the token, never carry forward).
|
|
466
|
+
*/
|
|
467
|
+
function mfaLifetimeFromAccessToken(accessToken?: string): Partial<SessionData> {
|
|
468
|
+
if (!accessToken) return {};
|
|
469
|
+
|
|
470
|
+
const decoded = decodeIdpAccessToken(accessToken);
|
|
471
|
+
if (!decoded) return {};
|
|
472
|
+
|
|
473
|
+
const fields: Partial<SessionData> = {};
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* *** TEST THE CONVERTED VALUE, NOT THE CLAIM'S PRESENCE. ***
|
|
477
|
+
*
|
|
478
|
+
* This guard previously branched on `claim !== undefined && claim !== null`
|
|
479
|
+
* and then wrote `Number(claim) * 1000`. Those are two different questions,
|
|
480
|
+
* and for a non-numeric claim they disagree: the claim IS present, so the
|
|
481
|
+
* branch is taken, and the value written is NaN. That single NaN defeats both
|
|
482
|
+
* guards in this system at once, in opposite directions:
|
|
483
|
+
*
|
|
484
|
+
* - `mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined` is TRUE
|
|
485
|
+
* for NaN, so R908(e) certifies a ceremony it never observed and GRANTS
|
|
486
|
+
* mfaVerified.
|
|
487
|
+
* - consumers read `mfaExpiresAt || 0`, and NaN is FALSY, so that collapses
|
|
488
|
+
* to 0, `0 > 0` is false, and the session NEVER expires.
|
|
489
|
+
*
|
|
490
|
+
* Net: `mfaVerified: true` that nothing can ever expire — the exact
|
|
491
|
+
* pre-339563 fail-open described at the top of this function, restored
|
|
492
|
+
* through a malformed claim, and silent (neither warning below fires on NaN).
|
|
493
|
+
*
|
|
494
|
+
* So a claim that will not convert is treated as NO CLAIM: the key is OMITTED
|
|
495
|
+
* and R908(e) withholds the grant, identical to the absent-claim lane. Absent
|
|
496
|
+
* and unreadable are the same epistemic state — we do not know the expiry —
|
|
497
|
+
* and they must not produce opposite security outcomes.
|
|
498
|
+
*
|
|
499
|
+
* Omitted, never set-to-undefined: updateSession() merges with
|
|
500
|
+
* `{ ...current, ...updates }`, so a present-but-undefined key would CLOBBER
|
|
501
|
+
* a good stored value. Pinned by test/mfa-expiry-malformed-claim.test.cjs.
|
|
502
|
+
*
|
|
503
|
+
* *** `Number.isFinite` ALONE IS NOT ENOUGH — IT MUST ALSO BE POSITIVE. ***
|
|
504
|
+
* `Number([])` is 0, and 0 is perfectly finite. But the consumer guard reads
|
|
505
|
+
* `mfaExpiresAt || 0`, and 0 is FALSY, so a stored zero is indistinguishable
|
|
506
|
+
* from a missing value and fails open in precisely the same way NaN does —
|
|
507
|
+
* while still passing `!== undefined` and so still earning the R908(e) grant.
|
|
508
|
+
* The honest invariant is not "is it a number" but "can this value ever
|
|
509
|
+
* expire a session", and only a positive one can. A zero/negative
|
|
510
|
+
* `mfa_expires` is meaningless anyway (epoch 1970 or earlier).
|
|
511
|
+
*
|
|
512
|
+
* The `typeof` gate rejects objects and arrays outright rather than letting
|
|
513
|
+
* JS coercion invent a number for them: `[]` -> 0 and `[5]` -> 5 are accidents
|
|
514
|
+
* of coercion, not claims the IDP made.
|
|
515
|
+
*/
|
|
516
|
+
const claimToNumber = (claim: unknown): number | undefined => {
|
|
517
|
+
if (typeof claim !== 'number' && typeof claim !== 'string') return undefined;
|
|
518
|
+
if (claim === '') return undefined;
|
|
519
|
+
const n = Number(claim);
|
|
520
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Claims are Unix SECONDS; SessionData stores milliseconds.
|
|
524
|
+
const mfaExpires = claimToNumber(decoded.mfa_expires);
|
|
525
|
+
if (mfaExpires !== undefined) {
|
|
526
|
+
fields.mfaExpiresAt = mfaExpires * 1000;
|
|
527
|
+
}
|
|
528
|
+
const mfaTime = claimToNumber(decoded.mfa_time);
|
|
529
|
+
if (mfaTime !== undefined) {
|
|
530
|
+
fields.mfaCompletedAt = mfaTime * 1000;
|
|
531
|
+
}
|
|
532
|
+
const mfaValidityHours = claimToNumber(decoded.mfa_validity_hours);
|
|
533
|
+
if (mfaValidityHours !== undefined) {
|
|
534
|
+
fields.mfaValidityHours = mfaValidityHours;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return fields;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Transitions a session to a MFA-completed state.
|
|
542
|
+
* @param sessionToken The session token to update.
|
|
543
|
+
* @param tokens The new tokens received after MFA completion.
|
|
544
|
+
* @param mfaMethod The MFA method used (email, sms, totp) - required for token refresh.
|
|
545
|
+
* @returns The updated session data.
|
|
546
|
+
*/
|
|
547
|
+
export async function transitionTo2FASession(
|
|
548
|
+
sessionToken: string,
|
|
549
|
+
tokens: {
|
|
550
|
+
accessToken?: string;
|
|
551
|
+
refreshToken?: string;
|
|
552
|
+
accessTokenExpires?: number;
|
|
553
|
+
refreshTokenExpires?: number;
|
|
554
|
+
// Support new field names
|
|
555
|
+
idpAccessToken?: string;
|
|
556
|
+
idpRefreshToken?: string;
|
|
557
|
+
idpAccessTokenExpires?: number;
|
|
558
|
+
idpRefreshTokenExpires?: number;
|
|
559
|
+
},
|
|
560
|
+
mfaMethod?: 'email' | 'sms' | 'totp'
|
|
561
|
+
): Promise<SessionData | null> {
|
|
562
|
+
const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
|
|
563
|
+
|
|
564
|
+
console.log('[transitionTo2FASession] Called with:', {
|
|
565
|
+
sessionToken: tokenLogId(sessionToken),
|
|
566
|
+
mfaMethod,
|
|
567
|
+
hasAccessToken: !!newAccessToken,
|
|
568
|
+
hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Extract bearerKeyId from the new access token (IDP may use different key after 2FA)
|
|
572
|
+
let bearerKeyId: string | undefined;
|
|
573
|
+
if (newAccessToken) {
|
|
574
|
+
bearerKeyId = extractKidFromToken(newAccessToken);
|
|
575
|
+
if (bearerKeyId) {
|
|
576
|
+
console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 339563: derive the expiry ONCE - it is both a field to write and the
|
|
581
|
+
// evidence test below. Re-deriving it twice invites the two reads to drift.
|
|
582
|
+
const mfaLifetime = mfaLifetimeFromAccessToken(newAccessToken);
|
|
583
|
+
|
|
584
|
+
// *** R908(e). ABSENCE OF THE EXPIRY IS NOT A COMPLETED CEREMONY. ***
|
|
585
|
+
//
|
|
586
|
+
// `mfaExpiresAt` is the only evidence we hold that a real MFA ceremony took
|
|
587
|
+
// place and when it lapses. When the stepped-up token carries no `mfa_expires`
|
|
588
|
+
// claim we do NOT know that this session is MFA-verified, and R908(e) binds
|
|
589
|
+
// the consumer to the third arm:
|
|
590
|
+
//
|
|
591
|
+
// producers degrade -> consumers RE-CHALLENGE -> nobody infers a
|
|
592
|
+
// negative from a silence.
|
|
593
|
+
//
|
|
594
|
+
// So: never mint `mfaVerified: true` off a token that carries no expiry (that
|
|
595
|
+
// is forging evidence of a ceremony we did not observe - 12188/12390), and
|
|
596
|
+
// never hard-refuse the user either (R486: null is "cannot determine", not
|
|
597
|
+
// "not 2FA-backed"). Leave the session un-verified and set `nextStep:
|
|
598
|
+
// 'mfa_verify'`, which is the field the gate already reads to mean "a usable
|
|
599
|
+
// method exists; challenge them" (SessionModel.ts:136).
|
|
600
|
+
const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
|
|
601
|
+
|
|
602
|
+
// Support both old and new field names in input
|
|
603
|
+
// CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
|
|
604
|
+
// auth.ts session callback reads twoFactorComplete, so we must set it here
|
|
605
|
+
const updates: Partial<SessionData> = {
|
|
606
|
+
idpAccessToken: newAccessToken,
|
|
607
|
+
idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
|
|
608
|
+
idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
|
|
609
|
+
mfaVerified: mfaEvidencePresent,
|
|
610
|
+
twoFactorComplete: mfaEvidencePresent, // Legacy field - required by auth.ts session callback
|
|
611
|
+
mfaMethod: mfaMethod,
|
|
612
|
+
// Update bearerKeyId if extracted from new token
|
|
613
|
+
...(bearerKeyId && { bearerKeyId }),
|
|
614
|
+
// 339563: write the expiry in the SAME update that sets the flag. We already
|
|
615
|
+
// hold the stepped-up token here and already decode its header above.
|
|
616
|
+
...mfaLifetime,
|
|
617
|
+
// R908(e): no evidence -> route to the challenge, do not grant and do not eject.
|
|
618
|
+
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' as const }),
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
if (!mfaEvidencePresent) {
|
|
622
|
+
console.warn(
|
|
623
|
+
'[transitionTo2FASession] R908(e): the stepped-up token carries NO mfa_expires ' +
|
|
624
|
+
'claim, so there is no evidence of an MFA ceremony to record. NOT setting ' +
|
|
625
|
+
'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
|
|
626
|
+
'If this fires for a lane that DID complete a real ceremony, the defect is ' +
|
|
627
|
+
'on the emit side (R908(b)) - do not "fix" it by minting the flag here.',
|
|
628
|
+
{
|
|
629
|
+
sessionToken: tokenLogId(sessionToken),
|
|
630
|
+
mfaMethod,
|
|
631
|
+
hasAccessToken: !!newAccessToken,
|
|
632
|
+
}
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
|
|
637
|
+
if (refreshExpires !== undefined) {
|
|
638
|
+
updates.idpRefreshTokenExpires = refreshExpires;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
console.log('[transitionTo2FASession] Updates to apply:', {
|
|
642
|
+
mfaVerified: updates.mfaVerified,
|
|
643
|
+
twoFactorComplete: (updates as any).twoFactorComplete,
|
|
644
|
+
mfaMethod: updates.mfaMethod,
|
|
645
|
+
hasIdpAccessToken: !!updates.idpAccessToken,
|
|
646
|
+
// 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
|
|
647
|
+
// the passwordless/primary-credential lane (token carried no mfa_expires).
|
|
648
|
+
// Since R908(e) that absence no longer fails OPEN: it withholds mfaVerified
|
|
649
|
+
// and sets nextStep=mfa_verify instead, so log both together - the pair is
|
|
650
|
+
// the whole decision.
|
|
651
|
+
mfaExpiresAt: updates.mfaExpiresAt,
|
|
652
|
+
mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
|
|
653
|
+
nextStep: (updates as any).nextStep,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const result = await updateSession(sessionToken, updates);
|
|
657
|
+
|
|
658
|
+
console.log('[transitionTo2FASession] Result:', {
|
|
659
|
+
success: !!result,
|
|
660
|
+
resultMfaVerified: result?.mfaVerified,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Updates IDP tokens and their expiries in an existing session.
|
|
668
|
+
* @param sessionToken The session token to update.
|
|
669
|
+
* @param idpAccessToken The new IDP access token.
|
|
670
|
+
* @param idpRefreshToken The new IDP refresh token.
|
|
671
|
+
* @param idpAccessTokenExpires The access token expiry timestamp.
|
|
672
|
+
* @param idpRefreshTokenExpires The refresh token expiry timestamp (optional).
|
|
673
|
+
* @returns The updated session data.
|
|
674
|
+
*/
|
|
675
|
+
export async function updateTokens(
|
|
676
|
+
sessionToken: string,
|
|
677
|
+
idpAccessToken: string,
|
|
678
|
+
idpRefreshToken: string,
|
|
679
|
+
idpAccessTokenExpires: number,
|
|
680
|
+
idpRefreshTokenExpires?: number
|
|
681
|
+
): Promise<SessionData | null> {
|
|
682
|
+
return updateSession(sessionToken, {
|
|
683
|
+
idpAccessToken,
|
|
684
|
+
idpRefreshToken,
|
|
685
|
+
idpAccessTokenExpires,
|
|
686
|
+
idpRefreshTokenExpires,
|
|
687
|
+
} as Partial<SessionData>);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Marks a session as having completed MFA.
|
|
692
|
+
*
|
|
693
|
+
* *** 339563: PASS `accessToken` - THE STEPPED-UP ONE THE IDP JUST RETURNED. ***
|
|
694
|
+
* Without it this writes a `mfaVerified: true` that no consumer can ever expire
|
|
695
|
+
* (see mfaLifetimeFromAccessToken above). The parameter is OPTIONAL only so
|
|
696
|
+
* that this published package keeps its existing signature for external
|
|
697
|
+
* callers; every in-tree caller supplies it, and omitting it re-opens the
|
|
698
|
+
* fail-open for that call site.
|
|
699
|
+
*
|
|
700
|
+
* It is NOT defaulted to the session's stored token on purpose. Reading it back
|
|
701
|
+
* would work today only because the sole caller happens to run `updateTokens()`
|
|
702
|
+
* first, and depending on undocumented call ORDERING is how this class of bug
|
|
703
|
+
* gets reintroduced. The token is an input, so take it as one.
|
|
704
|
+
*
|
|
705
|
+
* @param sessionToken The session token to update.
|
|
706
|
+
* @param accessToken The access token minted AFTER MFA completion, carrying the
|
|
707
|
+
* mfa_time / mfa_expires / mfa_validity_hours claim set.
|
|
708
|
+
* @returns The updated session data.
|
|
709
|
+
*/
|
|
710
|
+
export async function mark2FAComplete(
|
|
711
|
+
sessionToken: string,
|
|
712
|
+
accessToken?: string
|
|
713
|
+
): Promise<SessionData | null> {
|
|
714
|
+
const mfaLifetime = mfaLifetimeFromAccessToken(accessToken);
|
|
715
|
+
|
|
716
|
+
if (!accessToken) {
|
|
717
|
+
console.warn(
|
|
718
|
+
'[mark2FAComplete] 339563: called with NO access token, so no mfaExpiresAt ' +
|
|
719
|
+
'can be derived. This session\'s MFA state will not expire until its next ' +
|
|
720
|
+
'token refresh repairs it. Pass the stepped-up access token.'
|
|
721
|
+
);
|
|
722
|
+
} else if (mfaLifetime.mfaExpiresAt === undefined) {
|
|
723
|
+
console.warn(
|
|
724
|
+
'[mark2FAComplete] 339563: access token carries NO mfa_expires claim, so ' +
|
|
725
|
+
'this session has no MFA expiry. Expected on a primary-credential lane ' +
|
|
726
|
+
'(e.g. passwordless email); unexpected on an ordinary 2FA completion.'
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// R908(e): identical rule to transitionTo2FASession above - no expiry derived
|
|
731
|
+
// means no observed ceremony, so we re-challenge rather than mint the flag.
|
|
732
|
+
// This path matters more than its sibling: `mark2FAComplete(sessionToken)`
|
|
733
|
+
// with the accessToken omitted used to write a bare `mfaVerified: true` that
|
|
734
|
+
// NO consumer could ever expire, and it is reachable from route handlers that
|
|
735
|
+
// take their tokens from the request body.
|
|
736
|
+
const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
|
|
737
|
+
|
|
738
|
+
if (!mfaEvidencePresent) {
|
|
739
|
+
console.warn(
|
|
740
|
+
'[mark2FAComplete] R908(e): no mfa_expires could be derived, so mfaVerified ' +
|
|
741
|
+
'is NOT being set. Writing nextStep=mfa_verify instead so the consumer ' +
|
|
742
|
+
're-challenges. A caller that needs the grant must pass the stepped-up ' +
|
|
743
|
+
'access token that carries the claim.'
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return updateSession(sessionToken, {
|
|
748
|
+
mfaVerified: mfaEvidencePresent,
|
|
749
|
+
...mfaLifetime,
|
|
750
|
+
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' as const }),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Checks if MFA is complete for a session.
|
|
756
|
+
* @param sessionToken The session token.
|
|
757
|
+
* @returns True if MFA is complete.
|
|
758
|
+
*/
|
|
759
|
+
export async function is2FAComplete(sessionToken: string): Promise<boolean> {
|
|
760
|
+
const session = await getSession(sessionToken);
|
|
761
|
+
return session?.mfaVerified === true;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Gets IDP tokens from a session.
|
|
766
|
+
* @param sessionToken The session token.
|
|
767
|
+
* @returns The tokens or null if session not found.
|
|
768
|
+
*/
|
|
769
|
+
export async function getTokens(sessionToken: string): Promise<{
|
|
770
|
+
accessToken: string;
|
|
771
|
+
refreshToken: string;
|
|
772
|
+
// Also expose new names for clarity
|
|
773
|
+
idpAccessToken: string;
|
|
774
|
+
idpRefreshToken: string;
|
|
775
|
+
} | null> {
|
|
776
|
+
const session = await getSession(sessionToken);
|
|
777
|
+
if (!session || !session.idpAccessToken || !session.idpRefreshToken) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
// Legacy names for backward compatibility
|
|
782
|
+
accessToken: session.idpAccessToken,
|
|
783
|
+
refreshToken: session.idpRefreshToken,
|
|
784
|
+
// New normalized names
|
|
785
|
+
idpAccessToken: session.idpAccessToken,
|
|
786
|
+
idpRefreshToken: session.idpRefreshToken,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Refreshes a JWT session (placeholder for compatibility).
|
|
792
|
+
* @param sessionToken The session token.
|
|
793
|
+
* @returns The session data or null.
|
|
794
|
+
*/
|
|
795
|
+
export async function refreshJWTSession(sessionToken: string): Promise<SessionData | null> {
|
|
796
|
+
return getSession(sessionToken);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Clears all sessions (for testing only).
|
|
801
|
+
*/
|
|
802
|
+
export async function clearAllSessions(): Promise<void> {
|
|
803
|
+
console.warn('[SESSION-STORE] clearAllSessions called - this should only be used in testing');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Lists all sessions (for testing/debugging only).
|
|
808
|
+
* @returns An empty array (placeholder).
|
|
809
|
+
*/
|
|
810
|
+
export async function listAllSessions(): Promise<string[]> {
|
|
811
|
+
console.warn('[SESSION-STORE] listAllSessions called - this should only be used in testing');
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ===============================
|
|
816
|
+
// DISTRIBUTED REFRESH COORDINATION
|
|
817
|
+
// ===============================
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Attempt to acquire a refresh lock for a session
|
|
821
|
+
* Uses Redis SET with NX (Not eXists) for atomic lock acquisition
|
|
822
|
+
*/
|
|
823
|
+
export async function acquireRefreshLock(
|
|
824
|
+
sessionToken: string,
|
|
825
|
+
requestId: string,
|
|
826
|
+
maxWaitMs: number = 5000
|
|
827
|
+
): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
|
|
828
|
+
const lockKey = getRefreshLockKey(sessionToken);
|
|
829
|
+
const acquiredAt = Date.now();
|
|
830
|
+
const lockVersion = Math.floor(Math.random() * 1000000);
|
|
831
|
+
|
|
832
|
+
const lockInfo: RefreshLockInfo = {
|
|
833
|
+
sessionToken,
|
|
834
|
+
acquiredAt,
|
|
835
|
+
acquiredBy: requestId,
|
|
836
|
+
lockVersion
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
// Try to acquire the lock atomically
|
|
841
|
+
const result = await redis.set(lockKey, JSON.stringify(lockInfo), 'PX', REFRESH_LOCK_TTL * 1000, 'NX');
|
|
842
|
+
|
|
843
|
+
if (result === 'OK') {
|
|
844
|
+
console.debug('[SESSION-STORE] Refresh lock acquired', {
|
|
845
|
+
sessionToken: tokenLogId(sessionToken),
|
|
846
|
+
requestId,
|
|
847
|
+
lockVersion
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
return { acquired: true, lockInfo };
|
|
851
|
+
} else {
|
|
852
|
+
// Lock already exists, check if we should wait
|
|
853
|
+
if (maxWaitMs > 0) {
|
|
854
|
+
console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
|
|
855
|
+
sessionToken: tokenLogId(sessionToken),
|
|
856
|
+
requestId,
|
|
857
|
+
maxWaitMs
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
return await waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
|
|
864
|
+
sessionToken: tokenLogId(sessionToken),
|
|
865
|
+
requestId
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
return { acquired: false };
|
|
869
|
+
}
|
|
870
|
+
} catch (error) {
|
|
871
|
+
console.error('[SESSION-STORE] Failed to acquire refresh lock', {
|
|
872
|
+
sessionToken: tokenLogId(sessionToken),
|
|
873
|
+
requestId,
|
|
874
|
+
error: error instanceof Error ? error.message : String(error)
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
return { acquired: false };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Wait for a refresh lock to be released
|
|
883
|
+
*/
|
|
884
|
+
async function waitForRefreshLockRelease(
|
|
885
|
+
sessionToken: string,
|
|
886
|
+
requestId: string,
|
|
887
|
+
maxWaitMs: number
|
|
888
|
+
): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
|
|
889
|
+
const lockKey = getRefreshLockKey(sessionToken);
|
|
890
|
+
const startTime = Date.now();
|
|
891
|
+
const pollInterval = 100;
|
|
892
|
+
|
|
893
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
894
|
+
try {
|
|
895
|
+
const lockExists = await redis.exists(lockKey);
|
|
896
|
+
|
|
897
|
+
if (!lockExists) {
|
|
898
|
+
// Lock released - do not reacquire here to avoid double refresh
|
|
899
|
+
return { acquired: false };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Wait before next check
|
|
903
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
904
|
+
} catch (error) {
|
|
905
|
+
console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
|
|
906
|
+
sessionToken: tokenLogId(sessionToken),
|
|
907
|
+
requestId,
|
|
908
|
+
error: error instanceof Error ? error.message : String(error)
|
|
909
|
+
});
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
|
|
915
|
+
sessionToken: tokenLogId(sessionToken),
|
|
916
|
+
requestId,
|
|
917
|
+
waitedMs: Date.now() - startTime
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
return { acquired: false };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Release a refresh lock
|
|
925
|
+
* Uses Lua script to ensure atomic validation and release
|
|
926
|
+
*/
|
|
927
|
+
export async function releaseRefreshLock(
|
|
928
|
+
sessionToken: string,
|
|
929
|
+
requestId: string,
|
|
930
|
+
lockVersion?: number
|
|
931
|
+
): Promise<boolean> {
|
|
932
|
+
const lockKey = getRefreshLockKey(sessionToken);
|
|
933
|
+
|
|
934
|
+
try {
|
|
935
|
+
// Lua script for atomic lock validation and release
|
|
936
|
+
const luaScript = `
|
|
937
|
+
local lockKey = KEYS[1]
|
|
938
|
+
local expectedRequestId = ARGV[1]
|
|
939
|
+
local expectedVersion = ARGV[2]
|
|
940
|
+
|
|
941
|
+
local lockData = redis.call('GET', lockKey)
|
|
942
|
+
if not lockData then
|
|
943
|
+
return 0 -- Lock doesn't exist
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
local lockInfo = cjson.decode(lockData)
|
|
947
|
+
if lockInfo.acquiredBy == expectedRequestId then
|
|
948
|
+
if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
|
|
949
|
+
redis.call('DEL', lockKey)
|
|
950
|
+
return 1 -- Successfully released
|
|
951
|
+
else
|
|
952
|
+
return -2 -- Version mismatch
|
|
953
|
+
end
|
|
954
|
+
else
|
|
955
|
+
return -1 -- Wrong owner
|
|
956
|
+
end
|
|
957
|
+
`;
|
|
958
|
+
|
|
959
|
+
const result = await redis.eval(
|
|
960
|
+
luaScript,
|
|
961
|
+
1,
|
|
962
|
+
lockKey,
|
|
963
|
+
requestId,
|
|
964
|
+
lockVersion ? lockVersion.toString() : ''
|
|
965
|
+
) as number;
|
|
966
|
+
|
|
967
|
+
if (result === 1) {
|
|
968
|
+
console.debug('[SESSION-STORE] Refresh lock released successfully', {
|
|
969
|
+
sessionToken: tokenLogId(sessionToken),
|
|
970
|
+
requestId,
|
|
971
|
+
lockVersion
|
|
972
|
+
});
|
|
973
|
+
return true;
|
|
974
|
+
} else if (result === 0) {
|
|
975
|
+
console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
|
|
976
|
+
sessionToken: tokenLogId(sessionToken),
|
|
977
|
+
requestId
|
|
978
|
+
});
|
|
979
|
+
return false;
|
|
980
|
+
} else if (result === -1) {
|
|
981
|
+
console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
|
|
982
|
+
sessionToken: tokenLogId(sessionToken),
|
|
983
|
+
requestId
|
|
984
|
+
});
|
|
985
|
+
return false;
|
|
986
|
+
} else if (result === -2) {
|
|
987
|
+
console.error('[SESSION-STORE] Lock version mismatch during release', {
|
|
988
|
+
sessionToken: tokenLogId(sessionToken),
|
|
989
|
+
requestId,
|
|
990
|
+
lockVersion
|
|
991
|
+
});
|
|
992
|
+
return false;
|
|
993
|
+
} else {
|
|
994
|
+
console.error('[SESSION-STORE] Unexpected result from lock release script', {
|
|
995
|
+
sessionToken: tokenLogId(sessionToken),
|
|
996
|
+
requestId,
|
|
997
|
+
result
|
|
998
|
+
});
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
console.error('[SESSION-STORE] Failed to release refresh lock', {
|
|
1003
|
+
sessionToken: tokenLogId(sessionToken),
|
|
1004
|
+
requestId,
|
|
1005
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Check if a refresh lock exists for a session
|
|
1014
|
+
*/
|
|
1015
|
+
export async function checkRefreshLock(sessionToken: string): Promise<RefreshLockInfo | null> {
|
|
1016
|
+
const lockKey = getRefreshLockKey(sessionToken);
|
|
1017
|
+
|
|
1018
|
+
try {
|
|
1019
|
+
const lockData = await redis.get(lockKey);
|
|
1020
|
+
|
|
1021
|
+
if (!lockData) {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return JSON.parse(lockData) as RefreshLockInfo;
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
console.error('[SESSION-STORE] Failed to check refresh lock', {
|
|
1028
|
+
sessionToken: tokenLogId(sessionToken),
|
|
1029
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Simple check if a refresh is currently in progress for a session
|
|
1038
|
+
*/
|
|
1039
|
+
export async function isRefreshInProgress(sessionToken: string): Promise<boolean> {
|
|
1040
|
+
const lock = await checkRefreshLock(sessionToken);
|
|
1041
|
+
return lock !== null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Force cleanup of expired or orphaned refresh locks
|
|
1046
|
+
*/
|
|
1047
|
+
export async function cleanupRefreshLocks(): Promise<number> {
|
|
1048
|
+
console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
|
|
1049
|
+
return 0;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* PAY-2136 DIAGNOSTIC (BAPert 70873): which of the session keys EXIST for a token, right now.
|
|
1054
|
+
*
|
|
1055
|
+
* Names/presence only - this returns booleans and the key PREFIX (an app slug, not a secret), never a value and
|
|
1056
|
+
* never any part of the token. It exists so a NO_SESSION can be read for what it was: canonical row gone with the
|
|
1057
|
+
* ba: record alive (the :330 asymmetry), both gone (expired/deleted), or the store could not be asked ('error').
|
|
1058
|
+
*
|
|
1059
|
+
* EXISTS, not GET: it cannot parse, cannot throw on a malformed blob, and cannot return a credential. Each key is
|
|
1060
|
+
* asked separately so one timeout does not hide the other two. Never throws.
|
|
1061
|
+
*/
|
|
1062
|
+
export type KeyPresence = boolean | 'error';
|
|
1063
|
+
export async function describeSessionKeyPresence(
|
|
1064
|
+
sessionToken: string
|
|
1065
|
+
): Promise<{ prefix: string; canonical: KeyPresence; ba: KeyPresence; ver: KeyPresence }> {
|
|
1066
|
+
const ask = async (key: string): Promise<KeyPresence> => {
|
|
1067
|
+
try {
|
|
1068
|
+
return (await redis.exists(key)) > 0;
|
|
1069
|
+
} catch {
|
|
1070
|
+
return 'error';
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
let prefix = '';
|
|
1074
|
+
try {
|
|
1075
|
+
prefix = getSessionPrefix();
|
|
1076
|
+
} catch {
|
|
1077
|
+
prefix = '';
|
|
1078
|
+
}
|
|
1079
|
+
if (!sessionToken) return { prefix, canonical: false, ba: false, ver: false };
|
|
1080
|
+
let canonicalKey = '';
|
|
1081
|
+
let baKey = '';
|
|
1082
|
+
let verKey = '';
|
|
1083
|
+
try {
|
|
1084
|
+
canonicalKey = getSessionKey(sessionToken);
|
|
1085
|
+
baKey = getBetterAuthSessionKey(sessionToken, getAppSlug());
|
|
1086
|
+
verKey = getSessionVersionKey(sessionToken);
|
|
1087
|
+
} catch {
|
|
1088
|
+
return { prefix, canonical: 'error', ba: 'error', ver: 'error' };
|
|
1089
|
+
}
|
|
1090
|
+
const [canonical, ba, ver] = await Promise.all([ask(canonicalKey), ask(baKey), ask(verKey)]);
|
|
1091
|
+
return { prefix, canonical, ba, ver };
|
|
1092
|
+
}
|