@payez/next-mvp 3.9.1 → 4.0.0
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/dist/api/auth-handler.d.ts +1 -2
- package/dist/api/auth-handler.js +9 -9
- package/dist/api-handlers/account/change-password.js +110 -112
- package/dist/api-handlers/admin/analytics.d.ts +19 -20
- package/dist/api-handlers/admin/analytics.js +378 -379
- package/dist/api-handlers/admin/audit.d.ts +19 -20
- package/dist/api-handlers/admin/audit.js +213 -214
- package/dist/api-handlers/admin/index.d.ts +21 -22
- package/dist/api-handlers/admin/index.js +42 -43
- package/dist/api-handlers/admin/redis-sessions.d.ts +35 -36
- package/dist/api-handlers/admin/redis-sessions.js +203 -204
- package/dist/api-handlers/admin/sessions.d.ts +20 -21
- package/dist/api-handlers/admin/sessions.js +283 -284
- package/dist/api-handlers/admin/site-logs.d.ts +45 -46
- package/dist/api-handlers/admin/site-logs.js +317 -318
- package/dist/api-handlers/admin/stats.d.ts +20 -21
- package/dist/api-handlers/admin/stats.js +239 -240
- package/dist/api-handlers/admin/users.d.ts +19 -20
- package/dist/api-handlers/admin/users.js +221 -222
- package/dist/api-handlers/admin/vibe-data.d.ts +79 -80
- package/dist/api-handlers/admin/vibe-data.js +267 -268
- package/dist/api-handlers/auth/refresh.js +633 -635
- package/dist/api-handlers/auth/signout.js +186 -187
- package/dist/api-handlers/auth/status.js +4 -7
- package/dist/api-handlers/auth/update-session.d.ts +1 -1
- package/dist/api-handlers/auth/update-session.js +12 -14
- package/dist/api-handlers/auth/verify-code.d.ts +43 -43
- package/dist/api-handlers/auth/verify-code.js +90 -94
- package/dist/api-handlers/session/viability.js +114 -146
- package/dist/api-handlers/test/force-expire.js +59 -65
- package/dist/auth/auth-decision.js +182 -182
- package/dist/auth/better-auth.d.ts +3 -6
- package/dist/auth/better-auth.js +3 -6
- package/dist/auth/route-config.js +2 -2
- package/dist/auth/utils/token-utils.d.ts +83 -84
- package/dist/auth/utils/token-utils.js +218 -219
- package/dist/client/AuthContext.js +115 -112
- package/dist/client/better-auth-client.d.ts +1020 -1020
- package/dist/client/fetch-with-auth.js +2 -2
- package/dist/components/SessionSync.js +121 -119
- package/dist/components/account/MobileNavDrawer.js +64 -64
- package/dist/components/account/UserAvatarMenu.js +91 -88
- package/dist/components/admin/VibeAdminLayout.js +71 -69
- package/dist/hooks/useAuth.js +9 -7
- package/dist/hooks/useAuthSettings.js +93 -93
- package/dist/hooks/useAvailableProviders.d.ts +43 -45
- package/dist/hooks/useAvailableProviders.js +112 -108
- package/dist/hooks/useSessionExpiration.d.ts +2 -3
- package/dist/hooks/useSessionExpiration.js +2 -2
- package/dist/hooks/useViabilitySession.js +3 -2
- package/dist/index.js +4 -6
- package/dist/lib/app-slug.d.ts +95 -95
- package/dist/lib/app-slug.js +172 -172
- package/dist/lib/standardized-client-api.js +10 -5
- package/dist/lib/startup-init.js +21 -25
- package/dist/lib/test-aware-get-token.js +86 -81
- package/dist/lib/token-lifecycle.d.ts +78 -52
- package/dist/lib/token-lifecycle.js +360 -398
- package/dist/pages/admin-login/page.js +73 -83
- package/dist/pages/client-admin/ClientSiteAdminPage.js +179 -177
- package/dist/pages/login/page.js +202 -211
- package/dist/pages/showcase/ShowcasePage.js +142 -140
- package/dist/pages/test-env/EmergencyLogoutPage.js +99 -98
- package/dist/pages/test-env/JwtInspectPage.js +116 -114
- package/dist/pages/test-env/RefreshTokenPage.js +4 -2
- package/dist/pages/test-env/TestEnvPage.js +51 -49
- package/dist/pages/verify-code/page.js +412 -408
- package/dist/routes/auth/logout.d.ts +31 -31
- package/dist/routes/auth/logout.js +98 -113
- package/dist/routes/auth/nextauth.d.ts +14 -11
- package/dist/routes/auth/nextauth.js +25 -57
- package/dist/routes/auth/session.js +157 -179
- package/dist/routes/auth/viability.js +190 -201
- package/dist/server/auth.d.ts +50 -0
- package/dist/server/auth.js +62 -0
- package/dist/stores/authStore.js +19 -23
- package/dist/utils/logout.js +5 -5
- package/package.json +1 -3
- package/src/api/auth-handler.ts +550 -549
- package/src/api-handlers/account/change-password.ts +5 -8
- package/src/api-handlers/admin/analytics.ts +4 -6
- package/src/api-handlers/admin/audit.ts +5 -7
- package/src/api-handlers/admin/index.ts +1 -2
- package/src/api-handlers/admin/redis-sessions.ts +6 -8
- package/src/api-handlers/admin/sessions.ts +5 -7
- package/src/api-handlers/admin/site-logs.ts +8 -10
- package/src/api-handlers/admin/stats.ts +4 -6
- package/src/api-handlers/admin/users.ts +5 -7
- package/src/api-handlers/admin/vibe-data.ts +10 -12
- package/src/api-handlers/auth/refresh.ts +5 -7
- package/src/api-handlers/auth/signout.ts +5 -6
- package/src/api-handlers/auth/status.ts +4 -7
- package/src/api-handlers/auth/update-session.ts +123 -125
- package/src/api-handlers/auth/verify-code.ts +9 -13
- package/src/api-handlers/session/viability.ts +10 -47
- package/src/api-handlers/test/force-expire.ts +4 -11
- package/src/auth/auth-decision.ts +1 -1
- package/src/auth/better-auth.ts +138 -141
- package/src/auth/route-config.ts +219 -219
- package/src/auth/utils/token-utils.ts +0 -1
- package/src/client/AuthContext.tsx +6 -2
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +6 -5
- package/src/components/account/MobileNavDrawer.tsx +3 -3
- package/src/components/account/UserAvatarMenu.tsx +6 -3
- package/src/components/admin/VibeAdminLayout.tsx +4 -2
- package/src/config/logger.ts +1 -1
- package/src/hooks/useAuth.ts +117 -115
- package/src/hooks/useAuthSettings.ts +2 -2
- package/src/hooks/useAvailableProviders.ts +9 -5
- package/src/hooks/useSessionExpiration.ts +101 -102
- package/src/hooks/useViabilitySession.ts +336 -335
- package/src/index.ts +60 -63
- package/src/lib/api-handler.ts +0 -1
- package/src/lib/app-slug.ts +6 -6
- package/src/lib/standardized-client-api.ts +901 -895
- package/src/lib/startup-init.ts +243 -247
- package/src/lib/test-aware-get-token.ts +22 -12
- package/src/lib/token-lifecycle.ts +12 -53
- package/src/pages/admin-login/page.tsx +9 -17
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +4 -2
- package/src/pages/login/page.tsx +21 -28
- package/src/pages/showcase/ShowcasePage.tsx +4 -2
- package/src/pages/test-env/EmergencyLogoutPage.tsx +7 -6
- package/src/pages/test-env/JwtInspectPage.tsx +5 -3
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -155
- package/src/pages/test-env/TestEnvPage.tsx +4 -2
- package/src/pages/verify-code/page.tsx +10 -6
- package/src/routes/auth/logout.ts +7 -25
- package/src/routes/auth/nextauth.ts +45 -71
- package/src/routes/auth/session.ts +25 -50
- package/src/routes/auth/viability.ts +7 -19
- package/src/server/auth.ts +60 -0
- package/src/stores/authStore.ts +1899 -1904
- package/src/utils/logout.ts +30 -30
- package/src/auth/auth-options.ts +0 -237
- package/src/auth/callbacks/index.ts +0 -7
- package/src/auth/callbacks/jwt.ts +0 -382
- package/src/auth/callbacks/session.ts +0 -243
- package/src/auth/callbacks/signin.ts +0 -56
- package/src/auth/events/index.ts +0 -5
- package/src/auth/events/signout.ts +0 -33
- package/src/auth/providers/credentials.ts +0 -256
- package/src/auth/providers/index.ts +0 -6
- package/src/auth/providers/oauth.ts +0 -114
- package/src/lib/nextauth-secret.ts +0 -121
- package/src/types/next-auth.d.ts +0 -15
package/src/utils/logout.ts
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Simple Logout Utility for @payez/next-mvp
|
|
3
|
-
*
|
|
4
|
-
* Provides a clean logout function that:
|
|
5
|
-
* - Clears NextAuth session
|
|
6
|
-
* - Redirects to login page
|
|
7
|
-
* - No external dependencies
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Sign out the current user and redirect to login
|
|
14
|
-
*
|
|
15
|
-
* @param redirectUrl - URL to redirect to after logout (default: /account-auth/login)
|
|
16
|
-
*/
|
|
17
|
-
export async function logout(redirectUrl: string = '/account-auth/login'): Promise<void> {
|
|
18
|
-
try {
|
|
19
|
-
await signOut(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
} catch (error) {
|
|
24
|
-
console.error('Logout error:', error);
|
|
25
|
-
// Fallback: force redirect to login
|
|
26
|
-
if (typeof window !== 'undefined') {
|
|
27
|
-
window.location.href = redirectUrl;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Simple Logout Utility for @payez/next-mvp
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean logout function that:
|
|
5
|
+
* - Clears NextAuth session
|
|
6
|
+
* - Redirects to login page
|
|
7
|
+
* - No external dependencies
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { authClient } from '../client/better-auth-client';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sign out the current user and redirect to login
|
|
14
|
+
*
|
|
15
|
+
* @param redirectUrl - URL to redirect to after logout (default: /account-auth/login)
|
|
16
|
+
*/
|
|
17
|
+
export async function logout(redirectUrl: string = '/account-auth/login'): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await authClient.signOut();
|
|
20
|
+
if (typeof window !== 'undefined') {
|
|
21
|
+
window.location.href = redirectUrl;
|
|
22
|
+
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('Logout error:', error);
|
|
25
|
+
// Fallback: force redirect to login
|
|
26
|
+
if (typeof window !== 'undefined') {
|
|
27
|
+
window.location.href = redirectUrl;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/auth/auth-options.ts
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NextAuth Configuration (Refactored)
|
|
3
|
-
*
|
|
4
|
-
* This is the composition layer that wires together all auth modules.
|
|
5
|
-
* Individual logic lives in dedicated modules:
|
|
6
|
-
* - providers/ - Credentials and OAuth provider builders
|
|
7
|
-
* - callbacks/ - JWT, session, signIn callbacks
|
|
8
|
-
* - events/ - SignOut event handler
|
|
9
|
-
* - utils/ - Token utilities, IDP client
|
|
10
|
-
* - types/ - Type definitions
|
|
11
|
-
*
|
|
12
|
-
* CARGO CULT PATTERNS REMOVED:
|
|
13
|
-
* ============================
|
|
14
|
-
* The original auth-options.ts (1186 lines) had several anti-patterns that
|
|
15
|
-
* added complexity without benefit:
|
|
16
|
-
*
|
|
17
|
-
* 1. CALLBACK CONCURRENCY PROTECTION (removed)
|
|
18
|
-
* - shouldExecuteCallback() / markCallbackComplete()
|
|
19
|
-
* - A debouncing mechanism that tried to prevent callbacks from running
|
|
20
|
-
* too frequently. NextAuth already handles this properly.
|
|
21
|
-
* - Added complexity, caused race condition bugs, and leaked memory
|
|
22
|
-
* (Map entries never cleaned up).
|
|
23
|
-
*
|
|
24
|
-
* 2. SESSION RESTORATION (removed)
|
|
25
|
-
* - attemptSessionRestoration()
|
|
26
|
-
* - Tried to restore sessions by calling refresh endpoint from JWT callback.
|
|
27
|
-
* - Created circular dependencies and made debugging impossible.
|
|
28
|
-
* - Clean approach: Session missing = user re-authenticates. Simple.
|
|
29
|
-
*
|
|
30
|
-
* 3. VARIABLE NAME SOUP (normalized in Phase 3)
|
|
31
|
-
* - accessToken vs idpAccessToken vs oauthAccessToken
|
|
32
|
-
* - twoFactorComplete vs mfaVerified vs requiresTwoFactor
|
|
33
|
-
* - sessionToken vs redisSessionId
|
|
34
|
-
* - Now: Clear prefixes (idp*, oauth*, mfa*) with documented meanings.
|
|
35
|
-
*
|
|
36
|
-
* 4. INLINE EVERYTHING (modularized in Phase 2)
|
|
37
|
-
* - All logic was in one giant file with no separation of concerns.
|
|
38
|
-
* - Now: Each module has one job and can be tested independently.
|
|
39
|
-
*
|
|
40
|
-
* @version 2.0.0
|
|
41
|
-
* @since auth-refactor-2026-01
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
import type { NextAuthOptions } from 'next-auth';
|
|
45
|
-
import type { Provider } from 'next-auth/providers/index';
|
|
46
|
-
import { encode as defaultEncode, decode as defaultDecode } from 'next-auth/jwt';
|
|
47
|
-
import { getIDPClientConfig, type IDPClientConfig } from '../lib/idp-client-config';
|
|
48
|
-
import {
|
|
49
|
-
getSessionCookieName,
|
|
50
|
-
getSecureSessionCookieName,
|
|
51
|
-
getCsrfCookieName,
|
|
52
|
-
getSecureCsrfCookieName,
|
|
53
|
-
getCallbackUrlCookieName,
|
|
54
|
-
} from '../lib/app-slug';
|
|
55
|
-
|
|
56
|
-
// Module imports
|
|
57
|
-
import { createCredentialsProvider, buildOAuthProviders } from './providers';
|
|
58
|
-
import { jwtCallback, sessionCallback, signInCallback } from './callbacks';
|
|
59
|
-
import { handleSignOut } from './events';
|
|
60
|
-
|
|
61
|
-
// ============================================================================
|
|
62
|
-
// ENVIRONMENT HELPERS
|
|
63
|
-
// ============================================================================
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Get AUTH_ISSUER_URL for JWT issuer claim.
|
|
67
|
-
* Required for SSO across apps.
|
|
68
|
-
*/
|
|
69
|
-
function getAuthIssuerUrl(): string {
|
|
70
|
-
const url = process.env.AUTH_ISSUER_URL;
|
|
71
|
-
if (!url) {
|
|
72
|
-
throw new Error('AUTH_ISSUER_URL environment variable is REQUIRED');
|
|
73
|
-
}
|
|
74
|
-
return url;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ============================================================================
|
|
78
|
-
// BASE AUTH OPTIONS
|
|
79
|
-
// ============================================================================
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Base NextAuth configuration.
|
|
83
|
-
* Use getAuthOptions() for dynamic provider loading from IDP.
|
|
84
|
-
*/
|
|
85
|
-
export const authOptions: NextAuthOptions = {
|
|
86
|
-
// Session uses JWT strategy - JWT contains only redisSessionId
|
|
87
|
-
session: {
|
|
88
|
-
strategy: 'jwt',
|
|
89
|
-
maxAge: 30 * 24 * 60 * 60, // 30 days default, overridden by IDP config
|
|
90
|
-
},
|
|
91
|
-
|
|
92
|
-
// Custom JWT handling for SSO issuer
|
|
93
|
-
jwt: {
|
|
94
|
-
encode: async (params) => {
|
|
95
|
-
try {
|
|
96
|
-
const issuer = getAuthIssuerUrl();
|
|
97
|
-
console.log('[JWT_ENCODE] Encoding token:', {
|
|
98
|
-
hasToken: !!params.token,
|
|
99
|
-
hasSecret: !!params.secret,
|
|
100
|
-
secretLength: params.secret?.length || 0,
|
|
101
|
-
issuer,
|
|
102
|
-
tokenKeys: params.token ? Object.keys(params.token) : [],
|
|
103
|
-
});
|
|
104
|
-
const encoded = await defaultEncode({
|
|
105
|
-
...params,
|
|
106
|
-
secret: params.secret,
|
|
107
|
-
token: {
|
|
108
|
-
...params.token,
|
|
109
|
-
iss: issuer,
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
console.log('[JWT_ENCODE] Success, encoded length:', encoded?.length || 0);
|
|
113
|
-
return encoded;
|
|
114
|
-
} catch (error) {
|
|
115
|
-
console.error('[JWT_ENCODE] FAILED:', error);
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
decode: async (params) => {
|
|
120
|
-
const decoded = await defaultDecode(params);
|
|
121
|
-
if (decoded?.iss && decoded.iss !== getAuthIssuerUrl()) {
|
|
122
|
-
console.error('[JWT] Invalid issuer. Expected:', getAuthIssuerUrl(), 'Got:', decoded.iss);
|
|
123
|
-
return null; // Hard enforcement - reject mismatched issuers
|
|
124
|
-
}
|
|
125
|
-
return decoded;
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
// Cookie configuration for multi-app support
|
|
130
|
-
// In production, use __Secure- prefixed cookie names for enhanced security
|
|
131
|
-
cookies: {
|
|
132
|
-
sessionToken: {
|
|
133
|
-
name: process.env.NODE_ENV === 'production' ? getSecureSessionCookieName() : getSessionCookieName(),
|
|
134
|
-
options: {
|
|
135
|
-
httpOnly: true,
|
|
136
|
-
sameSite: 'lax',
|
|
137
|
-
path: '/',
|
|
138
|
-
secure: process.env.NODE_ENV === 'production',
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
csrfToken: {
|
|
142
|
-
name: process.env.NODE_ENV === 'production' ? getSecureCsrfCookieName() : getCsrfCookieName(),
|
|
143
|
-
options: {
|
|
144
|
-
httpOnly: true,
|
|
145
|
-
sameSite: 'lax',
|
|
146
|
-
path: '/',
|
|
147
|
-
secure: process.env.NODE_ENV === 'production',
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
callbackUrl: {
|
|
151
|
-
name: getCallbackUrlCookieName(),
|
|
152
|
-
options: {
|
|
153
|
-
sameSite: 'lax',
|
|
154
|
-
path: '/',
|
|
155
|
-
secure: process.env.NODE_ENV === 'production',
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
// Providers - credentials only in base, OAuth added dynamically
|
|
161
|
-
providers: [createCredentialsProvider()],
|
|
162
|
-
|
|
163
|
-
// Callbacks wired to modular implementations
|
|
164
|
-
callbacks: {
|
|
165
|
-
jwt: jwtCallback,
|
|
166
|
-
session: sessionCallback as any, // Type cast needed for NextAuth compatibility
|
|
167
|
-
signIn: signInCallback,
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
// Events
|
|
171
|
-
events: {
|
|
172
|
-
signOut: handleSignOut,
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
// Custom pages
|
|
176
|
-
pages: {
|
|
177
|
-
signIn: '/account-auth/login',
|
|
178
|
-
error: '/account-auth/login',
|
|
179
|
-
},
|
|
180
|
-
|
|
181
|
-
debug: false,
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// ============================================================================
|
|
185
|
-
// DYNAMIC AUTH OPTIONS (WITH IDP OAUTH PROVIDERS)
|
|
186
|
-
// ============================================================================
|
|
187
|
-
|
|
188
|
-
let cachedAuthOptions: NextAuthOptions | null = null;
|
|
189
|
-
let authOptionsPromise: Promise<NextAuthOptions> | null = null;
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Get auth options with dynamically loaded OAuth providers from IDP.
|
|
193
|
-
* Uses caching to avoid rebuilding on every request.
|
|
194
|
-
*/
|
|
195
|
-
export async function getAuthOptions(): Promise<NextAuthOptions> {
|
|
196
|
-
if (cachedAuthOptions) {
|
|
197
|
-
return cachedAuthOptions;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (authOptionsPromise) {
|
|
201
|
-
return authOptionsPromise;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
authOptionsPromise = buildDynamicAuthOptions();
|
|
205
|
-
cachedAuthOptions = await authOptionsPromise;
|
|
206
|
-
authOptionsPromise = null;
|
|
207
|
-
|
|
208
|
-
return cachedAuthOptions;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Build auth options with dynamic OAuth providers from IDP.
|
|
213
|
-
*/
|
|
214
|
-
async function buildDynamicAuthOptions(): Promise<NextAuthOptions> {
|
|
215
|
-
const idpConfig = await getIDPClientConfig();
|
|
216
|
-
const oauthProviders = buildOAuthProviders(idpConfig);
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
...authOptions,
|
|
220
|
-
secret: idpConfig.nextAuthSecret || process.env.NEXTAUTH_SECRET,
|
|
221
|
-
session: {
|
|
222
|
-
...authOptions.session,
|
|
223
|
-
maxAge: idpConfig.authSettings?.rememberMeDays
|
|
224
|
-
? idpConfig.authSettings.rememberMeDays * 24 * 60 * 60
|
|
225
|
-
: 30 * 24 * 60 * 60,
|
|
226
|
-
},
|
|
227
|
-
providers: [createCredentialsProvider(), ...oauthProviders],
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Clear cached auth options (when IDP config changes).
|
|
233
|
-
*/
|
|
234
|
-
export function clearAuthOptionsCache(): void {
|
|
235
|
-
cachedAuthOptions = null;
|
|
236
|
-
authOptionsPromise = null;
|
|
237
|
-
}
|
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JWT Callback
|
|
3
|
-
*
|
|
4
|
-
* Minimal token strategy - only store redisSessionId in JWT.
|
|
5
|
-
* All session data lives in Redis, not in the browser cookie.
|
|
6
|
-
*
|
|
7
|
-
* HANDLES:
|
|
8
|
-
* - Initial sign-in (credentials): Store redisSessionId from authorize()
|
|
9
|
-
* - Initial sign-in (OAuth): Register with IDP, create session, store redisSessionId
|
|
10
|
-
* - Subsequent requests: Validate session exists, return token
|
|
11
|
-
*
|
|
12
|
-
* @version 1.0.0
|
|
13
|
-
* @since auth-refactor-2026-01
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { JWT } from 'next-auth/jwt';
|
|
17
|
-
import type { User, Account } from 'next-auth';
|
|
18
|
-
import { createHmac } from 'crypto';
|
|
19
|
-
import { createSession, getSession } from '../../lib/session-store';
|
|
20
|
-
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
21
|
-
import { idpOAuthCallback } from '../utils/idp-client';
|
|
22
|
-
import {
|
|
23
|
-
decodeIdpAccessToken,
|
|
24
|
-
extractAmrFromToken,
|
|
25
|
-
expClaimToMs,
|
|
26
|
-
extractKidFromToken,
|
|
27
|
-
} from '../utils/token-utils';
|
|
28
|
-
// NOTE: Using any for sessionData until Phase 3 normalizes types
|
|
29
|
-
|
|
30
|
-
// ============================================================================
|
|
31
|
-
// VIBE ROLE FETCHING
|
|
32
|
-
// ============================================================================
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Generate HMAC signature for Vibe API request.
|
|
36
|
-
*/
|
|
37
|
-
function generateVibeSignature(
|
|
38
|
-
endpoint: string,
|
|
39
|
-
clientId: string,
|
|
40
|
-
timestamp: number
|
|
41
|
-
): string {
|
|
42
|
-
const signingKey = process.env.VIBE_SIGNING_KEY;
|
|
43
|
-
if (!signingKey) {
|
|
44
|
-
return '';
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const stringToSign = `${timestamp}|GET|${endpoint}|${clientId}`;
|
|
48
|
-
return createHmac('sha256', Buffer.from(signingKey, 'base64'))
|
|
49
|
-
.update(stringToSign)
|
|
50
|
-
.digest('base64');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Fetch user's roles from Vibe API.
|
|
55
|
-
* Returns empty array on failure (non-blocking).
|
|
56
|
-
* Uses HMAC signature for authentication when signing key is configured.
|
|
57
|
-
*/
|
|
58
|
-
async function fetchVibeRoles(userId: string, clientId: string): Promise<string[]> {
|
|
59
|
-
const vibeApiUrl = process.env.VIBE_API_URL;
|
|
60
|
-
if (!vibeApiUrl) {
|
|
61
|
-
return [];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const endpoint = `/api/v1/users/${userId}/roles`;
|
|
65
|
-
const timestamp = Math.floor(Date.now() / 1000);
|
|
66
|
-
const signature = generateVibeSignature(endpoint, clientId, timestamp);
|
|
67
|
-
|
|
68
|
-
// Build headers with optional signature
|
|
69
|
-
const headers: Record<string, string> = {
|
|
70
|
-
'Accept': 'application/json',
|
|
71
|
-
'X-Client-Id': clientId,
|
|
72
|
-
'X-Vibe-Client-Id': clientId,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
if (signature) {
|
|
76
|
-
headers['X-Vibe-Timestamp'] = String(timestamp);
|
|
77
|
-
headers['X-Vibe-Signature'] = signature;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const response = await fetch(
|
|
82
|
-
`${vibeApiUrl}${endpoint}`,
|
|
83
|
-
{
|
|
84
|
-
method: 'GET',
|
|
85
|
-
headers,
|
|
86
|
-
// 2 second timeout
|
|
87
|
-
signal: AbortSignal.timeout(2000),
|
|
88
|
-
}
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
if (!response.ok) {
|
|
92
|
-
console.warn('[JWT_CALLBACK] Failed to fetch Vibe roles:', response.status);
|
|
93
|
-
return [];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const data = await response.json();
|
|
97
|
-
const roles = data.roles?.map((r: any) => r.role_name || r) || [];
|
|
98
|
-
console.log('[JWT_CALLBACK] Fetched Vibe roles:', roles);
|
|
99
|
-
return roles;
|
|
100
|
-
} catch (error) {
|
|
101
|
-
console.warn('[JWT_CALLBACK] Error fetching Vibe roles (continuing with IDP roles only):', error);
|
|
102
|
-
return [];
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Merge IDP roles with Vibe roles, deduplicating.
|
|
108
|
-
*/
|
|
109
|
-
function mergeRoles(idpRoles: string[], vibeRoles: string[]): string[] {
|
|
110
|
-
return [...new Set([...idpRoles, ...vibeRoles])];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ============================================================================
|
|
114
|
-
// TYPES
|
|
115
|
-
// ============================================================================
|
|
116
|
-
|
|
117
|
-
interface JwtCallbackParams {
|
|
118
|
-
token: JWT;
|
|
119
|
-
user?: User | any;
|
|
120
|
-
account?: Account | null;
|
|
121
|
-
trigger?: 'signIn' | 'signUp' | 'update';
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
interface JwtCallbackResult extends JWT {
|
|
125
|
-
/** Redis session ID - the key to look up session data */
|
|
126
|
-
redisSessionId?: string;
|
|
127
|
-
/** User ID from IDP */
|
|
128
|
-
sub: string;
|
|
129
|
-
/** Error code if session validation failed */
|
|
130
|
-
error?: string;
|
|
131
|
-
/** Flag for OAuth users who need immediate 2FA redirect */
|
|
132
|
-
requiresTwoFactorRedirect?: boolean;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ============================================================================
|
|
136
|
-
// JWT CALLBACK
|
|
137
|
-
// ============================================================================
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* JWT callback - builds the NextAuth JWT token.
|
|
141
|
-
*
|
|
142
|
-
* MINIMAL TOKEN STRATEGY:
|
|
143
|
-
* - Only store redisSessionId (key to Redis session)
|
|
144
|
-
* - All tokens and user data live in Redis
|
|
145
|
-
* - Browser cookie stays small and secure
|
|
146
|
-
*
|
|
147
|
-
* @param params - JWT callback parameters from NextAuth
|
|
148
|
-
* @returns JWT payload to store in browser cookie
|
|
149
|
-
*/
|
|
150
|
-
export async function jwtCallback({
|
|
151
|
-
token,
|
|
152
|
-
user,
|
|
153
|
-
account,
|
|
154
|
-
trigger,
|
|
155
|
-
}: JwtCallbackParams): Promise<JwtCallbackResult> {
|
|
156
|
-
console.log('[JWT_CALLBACK] Called with:', {
|
|
157
|
-
trigger,
|
|
158
|
-
hasAccount: !!account,
|
|
159
|
-
provider: account?.provider,
|
|
160
|
-
hasUser: !!user,
|
|
161
|
-
userEmail: user?.email,
|
|
162
|
-
existingRedisSessionId: token?.redisSessionId ? 'yes' : 'no',
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// -------------------------------------------------------------------------
|
|
166
|
-
// OAuth Sign-In: Register with IDP and create session
|
|
167
|
-
// -------------------------------------------------------------------------
|
|
168
|
-
|
|
169
|
-
if (account && account.provider !== 'credentials') {
|
|
170
|
-
console.log('[JWT_CALLBACK] Handling OAuth sign-in for provider:', account.provider);
|
|
171
|
-
return handleOAuthSignIn(token, user, account);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// -------------------------------------------------------------------------
|
|
175
|
-
// Credentials Sign-In: Session already created in authorize()
|
|
176
|
-
// -------------------------------------------------------------------------
|
|
177
|
-
|
|
178
|
-
if (user && (user as any).redisSessionId) {
|
|
179
|
-
// Credentials authorize() returns redisSessionId
|
|
180
|
-
const redisSessionId = (user as any).redisSessionId;
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
...token,
|
|
184
|
-
redisSessionId,
|
|
185
|
-
sub: user.id || token.sub || 'unknown',
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// -------------------------------------------------------------------------
|
|
190
|
-
// Subsequent Requests: Validate session exists
|
|
191
|
-
// -------------------------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
const redisSessionId = (user as any)?.redisSessionId || token?.redisSessionId || token?.redisSessionId;
|
|
194
|
-
|
|
195
|
-
if (!redisSessionId) {
|
|
196
|
-
return { ...token, error: 'NoSession', sub: token.sub || 'unknown' };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Validate session still exists in Redis
|
|
200
|
-
try {
|
|
201
|
-
const sessionData = await getSession(redisSessionId as string);
|
|
202
|
-
|
|
203
|
-
if (!sessionData) {
|
|
204
|
-
// Session expired or deleted
|
|
205
|
-
return { ...token, error: 'SessionNotFound', sub: token.sub || 'unknown' };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Check if refresh token has expired (session should be terminated)
|
|
209
|
-
if (sessionData.idpRefreshTokenExpires && Date.now() >= sessionData.idpRefreshTokenExpires) {
|
|
210
|
-
return { ...token, error: 'RefreshTokenExpired', sub: token.sub || 'unknown' };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Check if MFA has expired (requires step-up authentication)
|
|
214
|
-
if (sessionData.mfaExpiresAt && Date.now() > sessionData.mfaExpiresAt) {
|
|
215
|
-
return {
|
|
216
|
-
...token,
|
|
217
|
-
redisSessionId,
|
|
218
|
-
sub: sessionData.userId,
|
|
219
|
-
error: 'MfaExpired',
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
} catch (error) {
|
|
223
|
-
console.error('[JWT_CALLBACK] Session validation error:', error);
|
|
224
|
-
return { ...token, error: 'SessionError', sub: token.sub || 'unknown' };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Session is valid - return minimal token
|
|
228
|
-
return {
|
|
229
|
-
...token,
|
|
230
|
-
redisSessionId,
|
|
231
|
-
sub: token.sub || 'unknown',
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ============================================================================
|
|
236
|
-
// OAUTH SIGN-IN HANDLER
|
|
237
|
-
// ============================================================================
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Handle OAuth sign-in by registering with IDP and creating session.
|
|
241
|
-
*/
|
|
242
|
-
async function handleOAuthSignIn(
|
|
243
|
-
token: JWT,
|
|
244
|
-
user: User | any,
|
|
245
|
-
account: Account
|
|
246
|
-
): Promise<JwtCallbackResult> {
|
|
247
|
-
console.log('[JWT_CALLBACK] handleOAuthSignIn starting for:', {
|
|
248
|
-
provider: account.provider,
|
|
249
|
-
email: user?.email,
|
|
250
|
-
providerAccountId: account.providerAccountId,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
// Call IDP to register/authenticate OAuth user
|
|
255
|
-
const idpResult = await idpOAuthCallback({
|
|
256
|
-
provider: account.provider,
|
|
257
|
-
providerAccountId: account.providerAccountId,
|
|
258
|
-
email: user?.email || '',
|
|
259
|
-
name: user?.name || '',
|
|
260
|
-
image: user?.image || '',
|
|
261
|
-
accessToken: account.access_token,
|
|
262
|
-
refreshToken: account.refresh_token,
|
|
263
|
-
expiresAt: account.expires_at,
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
// Build session data using normalized field names
|
|
267
|
-
let sessionData: any;
|
|
268
|
-
let mfaVerified = false;
|
|
269
|
-
|
|
270
|
-
if (idpResult.success && idpResult.data?.accessToken) {
|
|
271
|
-
// IDP integration succeeded - we have IDP tokens
|
|
272
|
-
const decoded = decodeIdpAccessToken(idpResult.data.accessToken);
|
|
273
|
-
const amrClaims = decoded ? extractAmrFromToken(decoded) : [];
|
|
274
|
-
const acrLevel = decoded?.acr || '1';
|
|
275
|
-
|
|
276
|
-
// Extract kid from JWT header (CRITICAL: different from client_id in payload)
|
|
277
|
-
const bearerKeyId = extractKidFromToken(idpResult.data.accessToken);
|
|
278
|
-
if (bearerKeyId) {
|
|
279
|
-
console.log('[JWT_CALLBACK] Extracted bearerKeyId (kid) from JWT header:', bearerKeyId);
|
|
280
|
-
} else {
|
|
281
|
-
console.warn('[JWT_CALLBACK] No kid found in JWT header');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Check if MFA is required for this client
|
|
285
|
-
try {
|
|
286
|
-
const clientConfig = await getIDPClientConfig();
|
|
287
|
-
const require2FA = clientConfig?.authSettings?.require2FA ?? true;
|
|
288
|
-
mfaVerified = !require2FA; // If MFA not required, mark as verified
|
|
289
|
-
} catch {
|
|
290
|
-
// Default to requiring MFA if config unavailable
|
|
291
|
-
mfaVerified = false;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
sessionData = {
|
|
295
|
-
userId: idpResult.data.user?.userId?.toString() || account.providerAccountId,
|
|
296
|
-
email: idpResult.data.user?.email || user?.email || '',
|
|
297
|
-
name: idpResult.data.user?.fullName || user?.name || '',
|
|
298
|
-
roles: idpResult.data.user?.roles || [],
|
|
299
|
-
// IDP tokens (normalized names)
|
|
300
|
-
idpAccessToken: idpResult.data.accessToken,
|
|
301
|
-
idpRefreshToken: idpResult.data.refreshToken,
|
|
302
|
-
idpAccessTokenExpires: decoded?.exp ? expClaimToMs(decoded.exp) : Date.now() + 3600000,
|
|
303
|
-
decodedAccessToken: decoded || undefined,
|
|
304
|
-
// Bearer key ID from JWT header (NOT client_id from payload)
|
|
305
|
-
bearerKeyId,
|
|
306
|
-
// MFA state (normalized names)
|
|
307
|
-
mfaVerified,
|
|
308
|
-
authenticationMethods: amrClaims,
|
|
309
|
-
authenticationLevel: acrLevel,
|
|
310
|
-
// OAuth provider info (normalized names)
|
|
311
|
-
oauthProvider: account.provider,
|
|
312
|
-
oauthProviderToken: account.access_token,
|
|
313
|
-
oauthProviderRefreshToken: account.refresh_token,
|
|
314
|
-
// Multi-tenant info
|
|
315
|
-
idpClientId: decoded?.client_id,
|
|
316
|
-
merchantId: decoded?.merchant_id,
|
|
317
|
-
};
|
|
318
|
-
} else {
|
|
319
|
-
// IDP integration failed - create OAuth-only session
|
|
320
|
-
// This allows OAuth login to work even if IDP is unavailable
|
|
321
|
-
mfaVerified = true; // OAuth IS multi-factor (Google/Microsoft handle MFA)
|
|
322
|
-
sessionData = {
|
|
323
|
-
userId: account.providerAccountId,
|
|
324
|
-
email: user?.email || '',
|
|
325
|
-
name: user?.name || '',
|
|
326
|
-
roles: [],
|
|
327
|
-
mfaVerified: true, // OAuth IS multi-factor
|
|
328
|
-
oauthProvider: account.provider,
|
|
329
|
-
oauthProviderToken: account.access_token,
|
|
330
|
-
oauthProviderRefreshToken: account.refresh_token,
|
|
331
|
-
idpAccessTokenExpires: account.expires_at
|
|
332
|
-
? account.expires_at * 1000
|
|
333
|
-
: Date.now() + 3600000,
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// -------------------------------------------------------------------------
|
|
338
|
-
// ROLE MERGING: Fetch Vibe roles and merge with IDP roles
|
|
339
|
-
// -------------------------------------------------------------------------
|
|
340
|
-
const clientId = sessionData.idpClientId || process.env.IDP_CLIENT_ID || '';
|
|
341
|
-
if (clientId && sessionData.userId) {
|
|
342
|
-
const vibeRoles = await fetchVibeRoles(sessionData.userId, clientId);
|
|
343
|
-
// SECURITY: Filter out protected IDP-level role prefixes to prevent injection
|
|
344
|
-
const safeVibeRoles = vibeRoles.filter(r => !r.startsWith('payez_'));
|
|
345
|
-
const idpRoles = sessionData.roles || [];
|
|
346
|
-
sessionData.roles = mergeRoles(idpRoles, safeVibeRoles);
|
|
347
|
-
console.log('[JWT_CALLBACK] Merged roles:', {
|
|
348
|
-
idpRoles,
|
|
349
|
-
vibeRoles,
|
|
350
|
-
safeVibeRoles,
|
|
351
|
-
merged: sessionData.roles,
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Create Redis session
|
|
356
|
-
console.log('[JWT_CALLBACK] Creating Redis session for:', {
|
|
357
|
-
userId: sessionData.userId,
|
|
358
|
-
email: sessionData.email,
|
|
359
|
-
mfaVerified: sessionData.mfaVerified,
|
|
360
|
-
roles: sessionData.roles,
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
const redisSessionId = await createSession(sessionData);
|
|
364
|
-
|
|
365
|
-
console.log('[JWT_CALLBACK] Redis session created:', {
|
|
366
|
-
redisSessionId: redisSessionId ? redisSessionId.substring(0, 8) + '...' : 'NONE',
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
// Check if immediate MFA redirect is needed
|
|
370
|
-
const needsImmediateTwoFactor = !mfaVerified;
|
|
371
|
-
|
|
372
|
-
return {
|
|
373
|
-
...token,
|
|
374
|
-
redisSessionId,
|
|
375
|
-
sub: sessionData.userId,
|
|
376
|
-
requiresTwoFactorRedirect: needsImmediateTwoFactor,
|
|
377
|
-
};
|
|
378
|
-
} catch (error) {
|
|
379
|
-
console.error('[JWT_CALLBACK] handleOAuthSignIn FAILED:', error);
|
|
380
|
-
return { ...token, error: 'OAuthSignInFailed', sub: token.sub || 'unknown' };
|
|
381
|
-
}
|
|
382
|
-
}
|