@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
|
@@ -1,309 +1,309 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-Side Session Decoder
|
|
3
|
-
*
|
|
4
|
-
* Uses Better Auth's server-side session API to get the current session.
|
|
5
|
-
* Falls back to legacy JWT + Redis path if Better Auth session not found.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import 'server-only';
|
|
9
|
-
import { cookies } from 'next/headers';
|
|
10
|
-
import { type JWTPayload } from 'jose';
|
|
11
|
-
import { getSession, type SessionData } from '../lib/session-store';
|
|
12
|
-
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
13
|
-
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
14
|
-
import { ensureInitialized } from '../lib/startup-init';
|
|
15
|
-
import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
|
|
16
|
-
import { resolveVerifiedSessionCookie } from './verified-session-token';
|
|
17
|
-
import { tokenLogId } from '../lib/token-log-id';
|
|
18
|
-
|
|
19
|
-
export interface DecodedSession {
|
|
20
|
-
sessionData: SessionData;
|
|
21
|
-
jwtPayload: JWTPayload & { sessionToken?: string; redisSessionId?: string };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Try Better Auth's server-side session API.
|
|
26
|
-
* Returns a DecodedSession if Better Auth has an active session, null otherwise.
|
|
27
|
-
*/
|
|
28
|
-
async function tryBetterAuthSession(
|
|
29
|
-
requestCookies?: { get: (name: string) => { value: string } | undefined }
|
|
30
|
-
): Promise<DecodedSession | null> {
|
|
31
|
-
try {
|
|
32
|
-
const { getBetterAuthInstance } = await import('../auth/better-auth');
|
|
33
|
-
|
|
34
|
-
let auth: any = null;
|
|
35
|
-
try {
|
|
36
|
-
auth = await getBetterAuthInstance();
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (!auth?.api?.getSession) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Build headers from cookies for Better Auth to read
|
|
46
|
-
const cookieStore = requestCookies || (await cookies());
|
|
47
|
-
const headerObj = new Headers();
|
|
48
|
-
|
|
49
|
-
// Collect all cookies into a Cookie header
|
|
50
|
-
if ('getAll' in cookieStore && typeof cookieStore.getAll === 'function') {
|
|
51
|
-
const allCookies = (cookieStore as any).getAll();
|
|
52
|
-
const cookieStr = allCookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
|
|
53
|
-
headerObj.set('cookie', cookieStr);
|
|
54
|
-
} else {
|
|
55
|
-
// Fallback: read known cookie names
|
|
56
|
-
const sessionCookieName = getSessionCookieName();
|
|
57
|
-
const secureCookieName = getSecureSessionCookieName();
|
|
58
|
-
const parts: string[] = [];
|
|
59
|
-
const sc = cookieStore.get(secureCookieName);
|
|
60
|
-
if (sc?.value) parts.push(`${secureCookieName}=${sc.value}`);
|
|
61
|
-
const nc = cookieStore.get(sessionCookieName);
|
|
62
|
-
if (nc?.value) parts.push(`${sessionCookieName}=${nc.value}`);
|
|
63
|
-
if (parts.length > 0) headerObj.set('cookie', parts.join('; '));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const result = await auth.api.getSession({ headers: headerObj });
|
|
67
|
-
|
|
68
|
-
if (!result?.session || !result?.user) {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Read IDP tokens from BA Redis session (stored by callback route after OAuth)
|
|
73
|
-
let idpTokens: any = null;
|
|
74
|
-
try {
|
|
75
|
-
const { getRedis } = await import('../lib/redis');
|
|
76
|
-
const { getAppSlug } = await import('../lib/app-slug');
|
|
77
|
-
// KEY-COMPOSER MISMATCH FIX (2026-08-02).
|
|
78
|
-
// better-auth writes this key as `ba:${idpConfig.clientSlug || getAppSlug()}:...`
|
|
79
|
-
// (better-auth.ts:99). This reader used getAppSlug() ALONE, so on any
|
|
80
|
-
// deployment where the IDP returns a clientSlug that differs from
|
|
81
|
-
// APP_SLUG the lookup missed entirely: idpTokens stayed null, and every
|
|
82
|
-
// caller looked unauthenticated regardless of their real session.
|
|
83
|
-
// Observed live: writer produced ba:idp_admin_ui_graft:<token> while this
|
|
84
|
-
// reader asked for ba:payez_idp_admin_web:<token>.
|
|
85
|
-
//
|
|
86
|
-
// Try the writer's composition FIRST, then the original as a fallback.
|
|
87
|
-
// Additive on purpose: it can only ever find MORE sessions than before,
|
|
88
|
-
// so no consumer whose keys already resolved can regress.
|
|
89
|
-
let baRaw: string | null = null;
|
|
90
|
-
try {
|
|
91
|
-
const idpCfg = await getIDPClientConfig();
|
|
92
|
-
const writerSlug = (idpCfg as { clientSlug?: string } | null)?.clientSlug;
|
|
93
|
-
if (writerSlug) {
|
|
94
|
-
baRaw = await getRedis().get(`ba:${writerSlug}:${result.session.token}`);
|
|
95
|
-
}
|
|
96
|
-
} catch { /* config unavailable — fall through to the legacy key */ }
|
|
97
|
-
if (!baRaw) {
|
|
98
|
-
baRaw = await getRedis().get(`ba:${getAppSlug()}:${result.session.token}`);
|
|
99
|
-
}
|
|
100
|
-
if (baRaw) {
|
|
101
|
-
const baData = JSON.parse(baRaw);
|
|
102
|
-
idpTokens = baData.idpTokens;
|
|
103
|
-
}
|
|
104
|
-
} catch { /* Redis unavailable */ }
|
|
105
|
-
|
|
106
|
-
// LEG-1 FAIL-CLOSED ON UNUSABLE ROLES (card 340015, BAPert R412, 2026-09-03).
|
|
107
|
-
//
|
|
108
|
-
// `idpTokens.roles` is the ONLY source of roles for every role-gated route
|
|
109
|
-
// (see :109 below). When it is empty this leg used to return a perfectly
|
|
110
|
-
// valid-LOOKING session carrying `roles: []` — so the caller was
|
|
111
|
-
// authenticated but authorised for nothing. Every admin screen 403s and
|
|
112
|
-
// renders its chrome with no data and NO ERROR TEXT: it does not look
|
|
113
|
-
// broken, it looks empty.
|
|
114
|
-
//
|
|
115
|
-
// Three independent causes produce that state, so the test is on the
|
|
116
|
-
// CONDITION THAT ACTUALLY HARMS — "no usable roles" — rather than on any
|
|
117
|
-
// one route to it. A fourth cause is then covered for free:
|
|
118
|
-
// A MINT the password / admin-login lane never writes the block
|
|
119
|
-
// (it never calls storeIdpTokensInSession), so it is
|
|
120
|
-
// absent from birth.
|
|
121
|
-
// B CLOBBER better-auth's own 24h session refresh rebuilds the
|
|
122
|
-
// `ba:` record as `{session, user}` and drops the
|
|
123
|
-
// `idpTokens` SIBLING structurally, not conditionally
|
|
124
|
-
// (better-auth internal-adapter.mjs:287). This hits the
|
|
125
|
-
// OAuth and passkey lanes too — they are safe for 24
|
|
126
|
-
// hours and then stop being safe.
|
|
127
|
-
// C DERIVATION the block is PRESENT and fully populated with only
|
|
128
|
-
// `roles` empty (card 339748). `!idpTokens` is FALSE
|
|
129
|
-
// here and cannot see it — which is exactly why the
|
|
130
|
-
// predicate is `!idpTokens?.roles?.length` and not an
|
|
131
|
-
// absence check.
|
|
132
|
-
//
|
|
133
|
-
// Declining here hands the request to the legacy `{slug}:sess:` leg below,
|
|
134
|
-
// which carries the same roles. *** IF THAT RECORD IS ALSO ABSENT THE USER
|
|
135
|
-
// IS SIGNED OUT. THAT IS DELIBERATE AND ACCEPTED, NOT A REGRESSION *** —
|
|
136
|
-
// an honest, visible sign-out in place of a silently half-authorised
|
|
137
|
-
// session. Read the commit message before treating a sign-out here as a
|
|
138
|
-
// bug introduced by this fix.
|
|
139
|
-
if (!idpTokens?.roles?.length) {
|
|
140
|
-
console.warn(
|
|
141
|
-
'[DECODE-SESSION] Leg 1 declining: idpTokens unusable (no roles) for session',
|
|
142
|
-
tokenLogId(result.session.token),
|
|
143
|
-
'— falling through to the sess: leg'
|
|
144
|
-
);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Map Better Auth session + IDP tokens to SessionData
|
|
149
|
-
const sessionData: SessionData = {
|
|
150
|
-
userId: idpTokens?.userId || result.user.id || '',
|
|
151
|
-
email: idpTokens?.email || result.user.email || '',
|
|
152
|
-
name: idpTokens?.name || result.user.name || undefined,
|
|
153
|
-
roles: idpTokens?.roles || [],
|
|
154
|
-
idpAccessToken: idpTokens?.idpAccessToken,
|
|
155
|
-
idpRefreshToken: idpTokens?.idpRefreshToken,
|
|
156
|
-
idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
|
|
157
|
-
|| (result.session.expiresAt ? new Date(result.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
|
|
158
|
-
mfaVerified: idpTokens?.mfaVerified ?? false,
|
|
159
|
-
// R807 (362550): fail-closed collapse above stays; this preserves the bit it
|
|
160
|
-
// destroys. Explicit `false` counts as a signal - hence `!== undefined`.
|
|
161
|
-
mfaSignalPresent: idpTokens?.mfaVerified !== undefined,
|
|
162
|
-
/**
|
|
163
|
-
* ⚠ WAS HARDCODED `'google'` — third site of QAPert finding #3 (card
|
|
164
|
-
* 167892), found by sweeping rather than by report.
|
|
165
|
-
*
|
|
166
|
-
* Note the line ABOVE is already honest: mfaVerified derives from what
|
|
167
|
-
* was stored. This one asserted the provider anyway, so a passkey
|
|
168
|
-
* sign-in decoded here was relabelled a Google OAuth sign-in — the
|
|
169
|
-
* provenance the sign-in path deliberately records, overwritten on read.
|
|
170
|
-
*
|
|
171
|
-
* The IDP emits NO provider claim (DotNetPert), so when it was not
|
|
172
|
-
* stored the honest answer is UNSET, not a guess.
|
|
173
|
-
*/
|
|
174
|
-
oauthProvider: idpTokens?.oauthProvider ?? undefined,
|
|
175
|
-
idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId,
|
|
176
|
-
merchantId: idpTokens?.merchantId,
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
// Backwards compat: session.user.email works alongside session.email
|
|
180
|
-
(sessionData as any).user = {
|
|
181
|
-
id: sessionData.userId,
|
|
182
|
-
email: sessionData.email,
|
|
183
|
-
name: sessionData.name,
|
|
184
|
-
roles: sessionData.roles,
|
|
185
|
-
image: result.user.image,
|
|
186
|
-
oauthProvider: sessionData.oauthProvider,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const jwtPayload: DecodedSession['jwtPayload'] = {
|
|
190
|
-
sub: result.user.id,
|
|
191
|
-
email: result.user.email,
|
|
192
|
-
name: result.user.name,
|
|
193
|
-
iat: Math.floor(Date.now() / 1000),
|
|
194
|
-
exp: sessionData.idpAccessTokenExpires / 1000,
|
|
195
|
-
sessionToken: result.session.token,
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
return { sessionData, jwtPayload };
|
|
199
|
-
} catch (error) {
|
|
200
|
-
console.warn('[DECODE-SESSION] Better Auth session check failed:', error instanceof Error ? error.message : String(error));
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Decode the session from cookies.
|
|
207
|
-
* Tries Better Auth first, falls back to legacy JWT + Redis.
|
|
208
|
-
*
|
|
209
|
-
* @param requestCookies Optional cookie getter for API route context (NextRequest.cookies).
|
|
210
|
-
* If omitted, uses next/headers cookies() for server components.
|
|
211
|
-
*/
|
|
212
|
-
export async function decodeSession(
|
|
213
|
-
requestCookies?: { get: (name: string) => { value: string } | undefined }
|
|
214
|
-
): Promise<DecodedSession | null> {
|
|
215
|
-
try {
|
|
216
|
-
await ensureInitialized();
|
|
217
|
-
|
|
218
|
-
// Try Better Auth session first
|
|
219
|
-
const betterAuthSession = await tryBetterAuthSession(requestCookies);
|
|
220
|
-
if (betterAuthSession) {
|
|
221
|
-
return betterAuthSession;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Fall back to legacy JWT + Redis path
|
|
225
|
-
const cookieStore = requestCookies || (await cookies());
|
|
226
|
-
const sessionCookieName = getSessionCookieName();
|
|
227
|
-
const secureCookieName = getSecureSessionCookieName();
|
|
228
|
-
|
|
229
|
-
const cookieValue =
|
|
230
|
-
cookieStore.get(secureCookieName)?.value ||
|
|
231
|
-
cookieStore.get(sessionCookieName)?.value;
|
|
232
|
-
|
|
233
|
-
if (!cookieValue) {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const config = await getIDPClientConfig();
|
|
238
|
-
const secret = config.authSecret;
|
|
239
|
-
if (!secret) {
|
|
240
|
-
console.error('[DECODE-SESSION] No authSecret available from IDP config');
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// BA-SHAPE-AWARE FALLBACK LEG (183047, 2026-08-08).
|
|
245
|
-
//
|
|
246
|
-
// Better Auth mints session cookies as `{token}.{base64(hmac)}` — TWO
|
|
247
|
-
// segments, one dot. jose's `jwtVerify()` below requires a genuine
|
|
248
|
-
// compact JWS — THREE segments — and throws "Invalid Compact JWS" on a
|
|
249
|
-
// BA-shaped cookie unconditionally, before it ever looks at the secret.
|
|
250
|
-
// Since Better Auth is the only minter in production, that meant this
|
|
251
|
-
// fallback could never succeed for the cookies it actually receives: any
|
|
252
|
-
// single Better-Auth-primary miss (secondary-storage eviction, disabled
|
|
253
|
-
// cookie cache, in-flight secret divergence) read as total session
|
|
254
|
-
// death, because the ONLY fallback was structurally blind to the only
|
|
255
|
-
// shape it would ever be handed.
|
|
256
|
-
//
|
|
257
|
-
// PAY-1476: the shape still decides HOW to verify, but BOTH shapes are now
|
|
258
|
-
// verified, by the shared resolver. This branch used to take a BA-hmac
|
|
259
|
-
// cookie's token with no signature check ("Redis presence is the
|
|
260
|
-
// authority"), so `{leaked token}.{anything}` authenticated here - including
|
|
261
|
-
// behind the portal's admin API gate. The resolver checks the HMAC with
|
|
262
|
-
// timingSafeEqual (ba-hmac) or runs jwtVerify (jws) against the same
|
|
263
|
-
// env-first auth secret, and returns null on any failure.
|
|
264
|
-
const shaped = extractSessionTokenFromCookieValue(cookieValue);
|
|
265
|
-
const verified = await resolveVerifiedSessionCookie(cookieValue);
|
|
266
|
-
const sessionToken = verified?.token;
|
|
267
|
-
const payload: JWTPayload | undefined = verified?.payload;
|
|
268
|
-
|
|
269
|
-
if (!sessionToken) {
|
|
270
|
-
console.warn('[DECODE-SESSION] Session cookie did not verify (shape:', shaped?.shape ?? 'unrecognized', ')');
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const sessionData = await getSession(sessionToken);
|
|
275
|
-
if (!sessionData) {
|
|
276
|
-
// The OTHER half of R412(ii). There are two ways decodeSession returns
|
|
277
|
-
// null and they mean different things, so they must not both be silent:
|
|
278
|
-
// Leg 1 declining is routine and recoverable (this leg normally holds
|
|
279
|
-
// the roles), whereas reaching HERE means there is no fallback record
|
|
280
|
-
// either and the caller is about to be signed out. Different meanings,
|
|
281
|
-
// different remediations — do not ship a second silent path while
|
|
282
|
-
// fixing a silent path.
|
|
283
|
-
console.warn(
|
|
284
|
-
'[DECODE-SESSION] No sess: record for token',
|
|
285
|
-
tokenLogId(sessionToken),
|
|
286
|
-
'— no fallback available, session will be signed out'
|
|
287
|
-
);
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Backwards compat: session.user.email works alongside session.email
|
|
292
|
-
if (!(sessionData as any).user) {
|
|
293
|
-
(sessionData as any).user = {
|
|
294
|
-
id: sessionData.userId,
|
|
295
|
-
email: sessionData.email,
|
|
296
|
-
name: sessionData.name,
|
|
297
|
-
roles: sessionData.roles,
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
sessionData,
|
|
303
|
-
jwtPayload: (payload ?? { sessionToken }) as DecodedSession['jwtPayload'],
|
|
304
|
-
};
|
|
305
|
-
} catch (error) {
|
|
306
|
-
console.error('[DECODE-SESSION] Error:', error instanceof Error ? error.message : String(error));
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Server-Side Session Decoder
|
|
3
|
+
*
|
|
4
|
+
* Uses Better Auth's server-side session API to get the current session.
|
|
5
|
+
* Falls back to legacy JWT + Redis path if Better Auth session not found.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import 'server-only';
|
|
9
|
+
import { cookies } from 'next/headers';
|
|
10
|
+
import { type JWTPayload } from 'jose';
|
|
11
|
+
import { getSession, type SessionData } from '../lib/session-store';
|
|
12
|
+
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
13
|
+
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
14
|
+
import { ensureInitialized } from '../lib/startup-init';
|
|
15
|
+
import { extractSessionTokenFromCookieValue } from '../lib/session-cookie-shape';
|
|
16
|
+
import { resolveVerifiedSessionCookie } from './verified-session-token';
|
|
17
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
18
|
+
|
|
19
|
+
export interface DecodedSession {
|
|
20
|
+
sessionData: SessionData;
|
|
21
|
+
jwtPayload: JWTPayload & { sessionToken?: string; redisSessionId?: string };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Try Better Auth's server-side session API.
|
|
26
|
+
* Returns a DecodedSession if Better Auth has an active session, null otherwise.
|
|
27
|
+
*/
|
|
28
|
+
async function tryBetterAuthSession(
|
|
29
|
+
requestCookies?: { get: (name: string) => { value: string } | undefined }
|
|
30
|
+
): Promise<DecodedSession | null> {
|
|
31
|
+
try {
|
|
32
|
+
const { getBetterAuthInstance } = await import('../auth/better-auth');
|
|
33
|
+
|
|
34
|
+
let auth: any = null;
|
|
35
|
+
try {
|
|
36
|
+
auth = await getBetterAuthInstance();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!auth?.api?.getSession) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build headers from cookies for Better Auth to read
|
|
46
|
+
const cookieStore = requestCookies || (await cookies());
|
|
47
|
+
const headerObj = new Headers();
|
|
48
|
+
|
|
49
|
+
// Collect all cookies into a Cookie header
|
|
50
|
+
if ('getAll' in cookieStore && typeof cookieStore.getAll === 'function') {
|
|
51
|
+
const allCookies = (cookieStore as any).getAll();
|
|
52
|
+
const cookieStr = allCookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
|
|
53
|
+
headerObj.set('cookie', cookieStr);
|
|
54
|
+
} else {
|
|
55
|
+
// Fallback: read known cookie names
|
|
56
|
+
const sessionCookieName = getSessionCookieName();
|
|
57
|
+
const secureCookieName = getSecureSessionCookieName();
|
|
58
|
+
const parts: string[] = [];
|
|
59
|
+
const sc = cookieStore.get(secureCookieName);
|
|
60
|
+
if (sc?.value) parts.push(`${secureCookieName}=${sc.value}`);
|
|
61
|
+
const nc = cookieStore.get(sessionCookieName);
|
|
62
|
+
if (nc?.value) parts.push(`${sessionCookieName}=${nc.value}`);
|
|
63
|
+
if (parts.length > 0) headerObj.set('cookie', parts.join('; '));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await auth.api.getSession({ headers: headerObj });
|
|
67
|
+
|
|
68
|
+
if (!result?.session || !result?.user) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Read IDP tokens from BA Redis session (stored by callback route after OAuth)
|
|
73
|
+
let idpTokens: any = null;
|
|
74
|
+
try {
|
|
75
|
+
const { getRedis } = await import('../lib/redis');
|
|
76
|
+
const { getAppSlug } = await import('../lib/app-slug');
|
|
77
|
+
// KEY-COMPOSER MISMATCH FIX (2026-08-02).
|
|
78
|
+
// better-auth writes this key as `ba:${idpConfig.clientSlug || getAppSlug()}:...`
|
|
79
|
+
// (better-auth.ts:99). This reader used getAppSlug() ALONE, so on any
|
|
80
|
+
// deployment where the IDP returns a clientSlug that differs from
|
|
81
|
+
// APP_SLUG the lookup missed entirely: idpTokens stayed null, and every
|
|
82
|
+
// caller looked unauthenticated regardless of their real session.
|
|
83
|
+
// Observed live: writer produced ba:idp_admin_ui_graft:<token> while this
|
|
84
|
+
// reader asked for ba:payez_idp_admin_web:<token>.
|
|
85
|
+
//
|
|
86
|
+
// Try the writer's composition FIRST, then the original as a fallback.
|
|
87
|
+
// Additive on purpose: it can only ever find MORE sessions than before,
|
|
88
|
+
// so no consumer whose keys already resolved can regress.
|
|
89
|
+
let baRaw: string | null = null;
|
|
90
|
+
try {
|
|
91
|
+
const idpCfg = await getIDPClientConfig();
|
|
92
|
+
const writerSlug = (idpCfg as { clientSlug?: string } | null)?.clientSlug;
|
|
93
|
+
if (writerSlug) {
|
|
94
|
+
baRaw = await getRedis().get(`ba:${writerSlug}:${result.session.token}`);
|
|
95
|
+
}
|
|
96
|
+
} catch { /* config unavailable — fall through to the legacy key */ }
|
|
97
|
+
if (!baRaw) {
|
|
98
|
+
baRaw = await getRedis().get(`ba:${getAppSlug()}:${result.session.token}`);
|
|
99
|
+
}
|
|
100
|
+
if (baRaw) {
|
|
101
|
+
const baData = JSON.parse(baRaw);
|
|
102
|
+
idpTokens = baData.idpTokens;
|
|
103
|
+
}
|
|
104
|
+
} catch { /* Redis unavailable */ }
|
|
105
|
+
|
|
106
|
+
// LEG-1 FAIL-CLOSED ON UNUSABLE ROLES (card 340015, BAPert R412, 2026-09-03).
|
|
107
|
+
//
|
|
108
|
+
// `idpTokens.roles` is the ONLY source of roles for every role-gated route
|
|
109
|
+
// (see :109 below). When it is empty this leg used to return a perfectly
|
|
110
|
+
// valid-LOOKING session carrying `roles: []` — so the caller was
|
|
111
|
+
// authenticated but authorised for nothing. Every admin screen 403s and
|
|
112
|
+
// renders its chrome with no data and NO ERROR TEXT: it does not look
|
|
113
|
+
// broken, it looks empty.
|
|
114
|
+
//
|
|
115
|
+
// Three independent causes produce that state, so the test is on the
|
|
116
|
+
// CONDITION THAT ACTUALLY HARMS — "no usable roles" — rather than on any
|
|
117
|
+
// one route to it. A fourth cause is then covered for free:
|
|
118
|
+
// A MINT the password / admin-login lane never writes the block
|
|
119
|
+
// (it never calls storeIdpTokensInSession), so it is
|
|
120
|
+
// absent from birth.
|
|
121
|
+
// B CLOBBER better-auth's own 24h session refresh rebuilds the
|
|
122
|
+
// `ba:` record as `{session, user}` and drops the
|
|
123
|
+
// `idpTokens` SIBLING structurally, not conditionally
|
|
124
|
+
// (better-auth internal-adapter.mjs:287). This hits the
|
|
125
|
+
// OAuth and passkey lanes too — they are safe for 24
|
|
126
|
+
// hours and then stop being safe.
|
|
127
|
+
// C DERIVATION the block is PRESENT and fully populated with only
|
|
128
|
+
// `roles` empty (card 339748). `!idpTokens` is FALSE
|
|
129
|
+
// here and cannot see it — which is exactly why the
|
|
130
|
+
// predicate is `!idpTokens?.roles?.length` and not an
|
|
131
|
+
// absence check.
|
|
132
|
+
//
|
|
133
|
+
// Declining here hands the request to the legacy `{slug}:sess:` leg below,
|
|
134
|
+
// which carries the same roles. *** IF THAT RECORD IS ALSO ABSENT THE USER
|
|
135
|
+
// IS SIGNED OUT. THAT IS DELIBERATE AND ACCEPTED, NOT A REGRESSION *** —
|
|
136
|
+
// an honest, visible sign-out in place of a silently half-authorised
|
|
137
|
+
// session. Read the commit message before treating a sign-out here as a
|
|
138
|
+
// bug introduced by this fix.
|
|
139
|
+
if (!idpTokens?.roles?.length) {
|
|
140
|
+
console.warn(
|
|
141
|
+
'[DECODE-SESSION] Leg 1 declining: idpTokens unusable (no roles) for session',
|
|
142
|
+
tokenLogId(result.session.token),
|
|
143
|
+
'— falling through to the sess: leg'
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Map Better Auth session + IDP tokens to SessionData
|
|
149
|
+
const sessionData: SessionData = {
|
|
150
|
+
userId: idpTokens?.userId || result.user.id || '',
|
|
151
|
+
email: idpTokens?.email || result.user.email || '',
|
|
152
|
+
name: idpTokens?.name || result.user.name || undefined,
|
|
153
|
+
roles: idpTokens?.roles || [],
|
|
154
|
+
idpAccessToken: idpTokens?.idpAccessToken,
|
|
155
|
+
idpRefreshToken: idpTokens?.idpRefreshToken,
|
|
156
|
+
idpAccessTokenExpires: idpTokens?.idpAccessTokenExpires
|
|
157
|
+
|| (result.session.expiresAt ? new Date(result.session.expiresAt).getTime() : Date.now() + 24 * 60 * 60 * 1000),
|
|
158
|
+
mfaVerified: idpTokens?.mfaVerified ?? false,
|
|
159
|
+
// R807 (362550): fail-closed collapse above stays; this preserves the bit it
|
|
160
|
+
// destroys. Explicit `false` counts as a signal - hence `!== undefined`.
|
|
161
|
+
mfaSignalPresent: idpTokens?.mfaVerified !== undefined,
|
|
162
|
+
/**
|
|
163
|
+
* ⚠ WAS HARDCODED `'google'` — third site of QAPert finding #3 (card
|
|
164
|
+
* 167892), found by sweeping rather than by report.
|
|
165
|
+
*
|
|
166
|
+
* Note the line ABOVE is already honest: mfaVerified derives from what
|
|
167
|
+
* was stored. This one asserted the provider anyway, so a passkey
|
|
168
|
+
* sign-in decoded here was relabelled a Google OAuth sign-in — the
|
|
169
|
+
* provenance the sign-in path deliberately records, overwritten on read.
|
|
170
|
+
*
|
|
171
|
+
* The IDP emits NO provider claim (DotNetPert), so when it was not
|
|
172
|
+
* stored the honest answer is UNSET, not a guess.
|
|
173
|
+
*/
|
|
174
|
+
oauthProvider: idpTokens?.oauthProvider ?? undefined,
|
|
175
|
+
idpClientId: idpTokens?.idpClientId ?? idpTokens?.clientId,
|
|
176
|
+
merchantId: idpTokens?.merchantId,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Backwards compat: session.user.email works alongside session.email
|
|
180
|
+
(sessionData as any).user = {
|
|
181
|
+
id: sessionData.userId,
|
|
182
|
+
email: sessionData.email,
|
|
183
|
+
name: sessionData.name,
|
|
184
|
+
roles: sessionData.roles,
|
|
185
|
+
image: result.user.image,
|
|
186
|
+
oauthProvider: sessionData.oauthProvider,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const jwtPayload: DecodedSession['jwtPayload'] = {
|
|
190
|
+
sub: result.user.id,
|
|
191
|
+
email: result.user.email,
|
|
192
|
+
name: result.user.name,
|
|
193
|
+
iat: Math.floor(Date.now() / 1000),
|
|
194
|
+
exp: sessionData.idpAccessTokenExpires / 1000,
|
|
195
|
+
sessionToken: result.session.token,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { sessionData, jwtPayload };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.warn('[DECODE-SESSION] Better Auth session check failed:', error instanceof Error ? error.message : String(error));
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Decode the session from cookies.
|
|
207
|
+
* Tries Better Auth first, falls back to legacy JWT + Redis.
|
|
208
|
+
*
|
|
209
|
+
* @param requestCookies Optional cookie getter for API route context (NextRequest.cookies).
|
|
210
|
+
* If omitted, uses next/headers cookies() for server components.
|
|
211
|
+
*/
|
|
212
|
+
export async function decodeSession(
|
|
213
|
+
requestCookies?: { get: (name: string) => { value: string } | undefined }
|
|
214
|
+
): Promise<DecodedSession | null> {
|
|
215
|
+
try {
|
|
216
|
+
await ensureInitialized();
|
|
217
|
+
|
|
218
|
+
// Try Better Auth session first
|
|
219
|
+
const betterAuthSession = await tryBetterAuthSession(requestCookies);
|
|
220
|
+
if (betterAuthSession) {
|
|
221
|
+
return betterAuthSession;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Fall back to legacy JWT + Redis path
|
|
225
|
+
const cookieStore = requestCookies || (await cookies());
|
|
226
|
+
const sessionCookieName = getSessionCookieName();
|
|
227
|
+
const secureCookieName = getSecureSessionCookieName();
|
|
228
|
+
|
|
229
|
+
const cookieValue =
|
|
230
|
+
cookieStore.get(secureCookieName)?.value ||
|
|
231
|
+
cookieStore.get(sessionCookieName)?.value;
|
|
232
|
+
|
|
233
|
+
if (!cookieValue) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const config = await getIDPClientConfig();
|
|
238
|
+
const secret = config.authSecret;
|
|
239
|
+
if (!secret) {
|
|
240
|
+
console.error('[DECODE-SESSION] No authSecret available from IDP config');
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// BA-SHAPE-AWARE FALLBACK LEG (183047, 2026-08-08).
|
|
245
|
+
//
|
|
246
|
+
// Better Auth mints session cookies as `{token}.{base64(hmac)}` — TWO
|
|
247
|
+
// segments, one dot. jose's `jwtVerify()` below requires a genuine
|
|
248
|
+
// compact JWS — THREE segments — and throws "Invalid Compact JWS" on a
|
|
249
|
+
// BA-shaped cookie unconditionally, before it ever looks at the secret.
|
|
250
|
+
// Since Better Auth is the only minter in production, that meant this
|
|
251
|
+
// fallback could never succeed for the cookies it actually receives: any
|
|
252
|
+
// single Better-Auth-primary miss (secondary-storage eviction, disabled
|
|
253
|
+
// cookie cache, in-flight secret divergence) read as total session
|
|
254
|
+
// death, because the ONLY fallback was structurally blind to the only
|
|
255
|
+
// shape it would ever be handed.
|
|
256
|
+
//
|
|
257
|
+
// PAY-1476: the shape still decides HOW to verify, but BOTH shapes are now
|
|
258
|
+
// verified, by the shared resolver. This branch used to take a BA-hmac
|
|
259
|
+
// cookie's token with no signature check ("Redis presence is the
|
|
260
|
+
// authority"), so `{leaked token}.{anything}` authenticated here - including
|
|
261
|
+
// behind the portal's admin API gate. The resolver checks the HMAC with
|
|
262
|
+
// timingSafeEqual (ba-hmac) or runs jwtVerify (jws) against the same
|
|
263
|
+
// env-first auth secret, and returns null on any failure.
|
|
264
|
+
const shaped = extractSessionTokenFromCookieValue(cookieValue);
|
|
265
|
+
const verified = await resolveVerifiedSessionCookie(cookieValue);
|
|
266
|
+
const sessionToken = verified?.token;
|
|
267
|
+
const payload: JWTPayload | undefined = verified?.payload;
|
|
268
|
+
|
|
269
|
+
if (!sessionToken) {
|
|
270
|
+
console.warn('[DECODE-SESSION] Session cookie did not verify (shape:', shaped?.shape ?? 'unrecognized', ')');
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const sessionData = await getSession(sessionToken);
|
|
275
|
+
if (!sessionData) {
|
|
276
|
+
// The OTHER half of R412(ii). There are two ways decodeSession returns
|
|
277
|
+
// null and they mean different things, so they must not both be silent:
|
|
278
|
+
// Leg 1 declining is routine and recoverable (this leg normally holds
|
|
279
|
+
// the roles), whereas reaching HERE means there is no fallback record
|
|
280
|
+
// either and the caller is about to be signed out. Different meanings,
|
|
281
|
+
// different remediations — do not ship a second silent path while
|
|
282
|
+
// fixing a silent path.
|
|
283
|
+
console.warn(
|
|
284
|
+
'[DECODE-SESSION] No sess: record for token',
|
|
285
|
+
tokenLogId(sessionToken),
|
|
286
|
+
'— no fallback available, session will be signed out'
|
|
287
|
+
);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Backwards compat: session.user.email works alongside session.email
|
|
292
|
+
if (!(sessionData as any).user) {
|
|
293
|
+
(sessionData as any).user = {
|
|
294
|
+
id: sessionData.userId,
|
|
295
|
+
email: sessionData.email,
|
|
296
|
+
name: sessionData.name,
|
|
297
|
+
roles: sessionData.roles,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
sessionData,
|
|
303
|
+
jwtPayload: (payload ?? { sessionToken }) as DecodedSession['jwtPayload'],
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error('[DECODE-SESSION] Error:', error instanceof Error ? error.message : String(error));
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|