create-nuxt-base 1.1.2 → 1.2.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.
@@ -17,6 +17,7 @@ interface BetterAuthUser {
17
17
  * Stored auth state (persisted in cookie for SSR compatibility)
18
18
  */
19
19
  interface StoredAuthState {
20
+ authMode: 'cookie' | 'jwt';
20
21
  user: BetterAuthUser | null;
21
22
  }
22
23
 
@@ -25,6 +26,7 @@ interface StoredAuthState {
25
26
  */
26
27
  interface PasskeyAuthResult {
27
28
  error?: string;
29
+ session?: { token: string };
28
30
  success: boolean;
29
31
  user?: BetterAuthUser;
30
32
  }
@@ -33,22 +35,49 @@ interface PasskeyAuthResult {
33
35
  * Better Auth composable with client-side state management
34
36
  *
35
37
  * This composable manages auth state using:
36
- * 1. Client-side state stored in a cookie (for SSR compatibility)
37
- * 2. Better Auth's session endpoint as a validation check
38
+ * 1. Primary: Session cookies (more secure, HttpOnly)
39
+ * 2. Fallback: JWT tokens (when cookies are not available/working)
38
40
  *
39
- * The state is populated after login and cleared on logout.
41
+ * The auth mode is automatically detected:
42
+ * - If session cookie works → use cookies
43
+ * - If cookies fail (401) → switch to JWT mode
40
44
  */
41
45
  export function useBetterAuth() {
42
46
  // Use useCookie for SSR-compatible persistent state
43
- const authState = useCookie<StoredAuthState>('auth-state', {
44
- default: () => ({ user: null }),
47
+ // Note: No default value to prevent overwriting existing cookies during hydration
48
+ const authState = useCookie<StoredAuthState | null>('auth-state', {
45
49
  maxAge: 60 * 60 * 24 * 7, // 7 days
46
50
  sameSite: 'lax',
47
51
  });
48
52
 
49
- // Auth token cookie (for 2FA sessions where no session cookie is set)
50
- const authToken = useCookie<string | null>('auth-token', {
51
- default: () => null,
53
+ // On client, sync from browser cookie to ensure we have the latest value
54
+ // This prevents hydration mismatch where useCookie may return stale/null value
55
+ if (import.meta.client) {
56
+ try {
57
+ const cookieStr = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
58
+ if (cookieStr) {
59
+ const parts = cookieStr.split('=');
60
+ const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
61
+ if (value) {
62
+ const parsed = JSON.parse(value);
63
+ // Only update if the browser cookie has a user but useCookie doesn't
64
+ if (parsed?.user && !authState.value?.user) {
65
+ authState.value = parsed;
66
+ }
67
+ }
68
+ }
69
+ } catch {
70
+ // Ignore parse errors
71
+ }
72
+ }
73
+
74
+ // Initialize with default only on server if cookie doesn't exist
75
+ if (import.meta.server && (authState.value === null || authState.value === undefined)) {
76
+ authState.value = { user: null, authMode: 'cookie' };
77
+ }
78
+
79
+ // JWT token storage (used when cookies are not available)
80
+ const jwtToken = useCookie<string | null>('jwt-token', {
52
81
  maxAge: 60 * 60 * 24 * 7, // 7 days
53
82
  sameSite: 'lax',
54
83
  });
@@ -56,24 +85,141 @@ export function useBetterAuth() {
56
85
  // Loading state
57
86
  const isLoading = ref<boolean>(false);
58
87
 
88
+ // Auth mode: 'cookie' (default) or 'jwt' (fallback)
89
+ const authMode = computed(() => authState.value?.authMode || 'cookie');
90
+ const isJwtMode = computed(() => authMode.value === 'jwt');
91
+
59
92
  // Computed properties based on stored state
60
93
  const user = computed<BetterAuthUser | null>(() => authState.value?.user ?? null);
61
94
  const isAuthenticated = computed<boolean>(() => !!user.value);
62
95
  const isAdmin = computed<boolean>(() => user.value?.role === 'admin');
63
96
  const is2FAEnabled = computed<boolean>(() => user.value?.twoFactorEnabled ?? false);
64
97
 
98
+ /**
99
+ * Get the API base URL
100
+ */
101
+ function getApiBase(): string {
102
+ const isDev = import.meta.dev;
103
+ const runtimeConfig = useRuntimeConfig();
104
+ return isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
105
+ }
106
+
65
107
  /**
66
108
  * Set user data after successful login/signup
109
+ * Also manually writes to browser cookie for SSR compatibility
67
110
  */
68
- function setUser(userData: BetterAuthUser | null): void {
69
- authState.value = { user: userData };
111
+ function setUser(userData: BetterAuthUser | null, mode: 'cookie' | 'jwt' = 'cookie'): void {
112
+ const newState = { user: userData, authMode: mode };
113
+ authState.value = newState;
114
+
115
+ // Manually write to browser cookie for immediate SSR compatibility
116
+ if (import.meta.client) {
117
+ const maxAge = 60 * 60 * 24 * 7; // 7 days
118
+ document.cookie = `auth-state=${encodeURIComponent(JSON.stringify(newState))}; path=/; max-age=${maxAge}; samesite=lax`;
119
+ }
70
120
  }
71
121
 
72
122
  /**
73
123
  * Clear user data on logout
124
+ * Also manually clears browser cookies for SSR compatibility
74
125
  */
75
126
  function clearUser(): void {
76
- authState.value = { user: null };
127
+ const clearedState = { user: null, authMode: 'cookie' as const };
128
+ authState.value = clearedState;
129
+ jwtToken.value = null;
130
+
131
+ // Manually clear browser cookies for immediate SSR compatibility
132
+ if (import.meta.client) {
133
+ const maxAge = 60 * 60 * 24 * 7; // 7 days
134
+ document.cookie = `auth-state=${encodeURIComponent(JSON.stringify(clearedState))}; path=/; max-age=${maxAge}; samesite=lax`;
135
+ document.cookie = `jwt-token=; path=/; max-age=0`;
136
+
137
+ // Clear Better Auth session cookies (set by the API)
138
+ // These cookies may have different names depending on the configuration
139
+ const sessionCookieNames = ['better-auth.session_token', 'better-auth.session', '__Secure-better-auth.session_token', 'session_token', 'session'];
140
+
141
+ for (const name of sessionCookieNames) {
142
+ // Clear with different path variations
143
+ document.cookie = `${name}=; path=/; max-age=0`;
144
+ document.cookie = `${name}=; path=/api; max-age=0`;
145
+ document.cookie = `${name}=; path=/api/iam; max-age=0`;
146
+ document.cookie = `${name}=; path=/iam; max-age=0`;
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Switch to JWT mode and fetch a token
153
+ */
154
+ async function switchToJwtMode(): Promise<boolean> {
155
+ try {
156
+ const apiBase = getApiBase();
157
+ const response = await fetch(`${apiBase}/token`, {
158
+ method: 'GET',
159
+ credentials: 'include',
160
+ });
161
+
162
+ if (response.ok) {
163
+ const data = await response.json();
164
+ if (data.token) {
165
+ jwtToken.value = data.token;
166
+ if (authState.value) {
167
+ authState.value = { ...authState.value, authMode: 'jwt' };
168
+ }
169
+ console.debug('[Auth] Switched to JWT mode');
170
+ return true;
171
+ }
172
+ }
173
+ return false;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Refresh JWT token before it expires
181
+ */
182
+ async function refreshJwtToken(): Promise<boolean> {
183
+ if (!isJwtMode.value || !jwtToken.value) return false;
184
+ return switchToJwtMode();
185
+ }
186
+
187
+ /**
188
+ * Authenticated fetch wrapper
189
+ * Uses cookies by default, falls back to JWT if cookies fail
190
+ */
191
+ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
192
+ const headers = new Headers(options.headers);
193
+
194
+ // In JWT mode, add Authorization header
195
+ if (isJwtMode.value && jwtToken.value) {
196
+ headers.set('Authorization', `Bearer ${jwtToken.value}`);
197
+ }
198
+
199
+ const response = await fetch(url, {
200
+ ...options,
201
+ headers,
202
+ // Only include credentials in cookie mode
203
+ credentials: isJwtMode.value ? 'omit' : 'include',
204
+ });
205
+
206
+ // If we get 401 in cookie mode, try switching to JWT
207
+ if (response.status === 401 && !isJwtMode.value && isAuthenticated.value) {
208
+ console.debug('[Auth] Cookie auth failed, attempting JWT fallback...');
209
+ const switched = await switchToJwtMode();
210
+
211
+ if (switched) {
212
+ // Retry the request with JWT
213
+ headers.set('Authorization', `Bearer ${jwtToken.value}`);
214
+ return fetch(url, {
215
+ ...options,
216
+ headers,
217
+ credentials: 'omit',
218
+ });
219
+ }
220
+ }
221
+
222
+ return response;
77
223
  }
78
224
 
79
225
  /**
@@ -103,23 +249,21 @@ export function useBetterAuth() {
103
249
 
104
250
  // If session has user data, update our state
105
251
  if (session.value.data?.user) {
106
- setUser(session.value.data.user as BetterAuthUser);
252
+ setUser(session.value.data.user as BetterAuthUser, 'cookie');
253
+ // Pre-fetch JWT for fallback
254
+ switchToJwtMode().catch(() => {});
107
255
  return true;
108
256
  }
109
257
 
110
- // Session not found - check if we have a stored token cookie
111
- // If we have auth-state but no session, it might be a mismatch
112
- // For now, trust the stored state if token cookie exists
113
- const tokenCookie = useCookie('token');
114
- if (tokenCookie.value && authState.value?.user) {
115
- // We have both token and stored user - trust it
258
+ // Session not found from Better Auth API
259
+ // Trust the stored auth-state if user exists (e.g., after 2FA verification)
260
+ // The auth-state cookie is set by our application after successful login/2FA
261
+ if (authState.value?.user) {
262
+ // Pre-fetch JWT for fallback
263
+ switchToJwtMode().catch(() => {});
116
264
  return true;
117
265
  }
118
266
 
119
- // No valid session found - clear state
120
- if (authState.value?.user) {
121
- clearUser();
122
- }
123
267
  return false;
124
268
  } catch (error) {
125
269
  console.debug('Session validation failed:', error);
@@ -137,11 +281,23 @@ export function useBetterAuth() {
137
281
  try {
138
282
  const result = await authClient.signIn.email(params, options);
139
283
 
140
- // Check for successful response with user data
141
- if (result && 'user' in result && result.user) {
142
- setUser(result.user as BetterAuthUser);
143
- } else if (result && 'data' in result && result.data?.user) {
144
- setUser(result.data.user as BetterAuthUser);
284
+ // Extract token from response (JWT mode: cookies: false)
285
+ const resultAny = result as any;
286
+ const token = resultAny?.token || resultAny?.data?.token;
287
+ const userData = resultAny?.user || resultAny?.data?.user;
288
+
289
+ if (token) {
290
+ // JWT mode: Token is in the response
291
+ jwtToken.value = token;
292
+ if (userData) {
293
+ setUser(userData as BetterAuthUser, 'jwt');
294
+ }
295
+ console.debug('[Auth] JWT token received from login response');
296
+ } else if (userData) {
297
+ // Cookie mode: No token in response, use cookies
298
+ setUser(userData as BetterAuthUser, 'cookie');
299
+ // Try to get JWT token for fallback
300
+ switchToJwtMode().catch(() => {});
145
301
  }
146
302
 
147
303
  return result;
@@ -161,11 +317,22 @@ export function useBetterAuth() {
161
317
  try {
162
318
  const result = await authClient.signUp.email(params, options);
163
319
 
164
- // Check for successful response with user data
165
- if (result && 'user' in result && result.user) {
166
- setUser(result.user as BetterAuthUser);
167
- } else if (result && 'data' in result && result.data?.user) {
168
- setUser(result.data.user as BetterAuthUser);
320
+ // Extract token from response (JWT mode: cookies: false)
321
+ const resultAny = result as any;
322
+ const token = resultAny?.token || resultAny?.data?.token;
323
+ const userData = resultAny?.user || resultAny?.data?.user;
324
+
325
+ if (token) {
326
+ // JWT mode: Token is in the response
327
+ jwtToken.value = token;
328
+ if (userData) {
329
+ setUser(userData as BetterAuthUser, 'jwt');
330
+ }
331
+ console.debug('[Auth] JWT token received from signup response');
332
+ } else if (userData) {
333
+ // Cookie mode: No token in response, use cookies
334
+ setUser(userData as BetterAuthUser, 'cookie');
335
+ switchToJwtMode().catch(() => {});
169
336
  }
170
337
 
171
338
  return result;
@@ -205,16 +372,11 @@ export function useBetterAuth() {
205
372
  isLoading.value = true;
206
373
 
207
374
  try {
208
- // In development, use the Nuxt proxy to ensure cookies are sent correctly
209
- // In production, use the direct API URL
210
- const isDev = import.meta.dev;
211
- const runtimeConfig = useRuntimeConfig();
212
- const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
375
+ const apiBase = getApiBase();
213
376
 
214
377
  // Step 1: Get authentication options from server
215
- const optionsResponse = await fetch(`${apiBase}/passkey/generate-authenticate-options`, {
378
+ const optionsResponse = await fetchWithAuth(`${apiBase}/passkey/generate-authenticate-options`, {
216
379
  method: 'GET',
217
- credentials: 'include',
218
380
  });
219
381
 
220
382
  if (!optionsResponse.ok) {
@@ -258,11 +420,11 @@ export function useBetterAuth() {
258
420
 
259
421
  // Step 5: Verify with server
260
422
  // Note: The server expects { response: credentialData } format (matching @simplewebauthn/browser output)
261
- const authResponse = await fetch(`${apiBase}/passkey/verify-authentication`, {
423
+ // Include challengeId for JWT mode (database challenge storage)
424
+ const authResponse = await fetchWithAuth(`${apiBase}/passkey/verify-authentication`, {
262
425
  method: 'POST',
263
- credentials: 'include',
264
426
  headers: { 'Content-Type': 'application/json' },
265
- body: JSON.stringify({ response: credentialBody }),
427
+ body: JSON.stringify({ challengeId: options.challengeId, response: credentialBody }),
266
428
  });
267
429
 
268
430
  const result = await authResponse.json();
@@ -273,10 +435,19 @@ export function useBetterAuth() {
273
435
 
274
436
  // Store user data after successful passkey login
275
437
  if (result.user) {
276
- setUser(result.user as BetterAuthUser);
438
+ setUser(result.user as BetterAuthUser, 'cookie');
439
+ switchToJwtMode().catch(() => {});
440
+ } else if (result.session?.token) {
441
+ // Passkey auth returns session without user in JWT mode
442
+ // Store the session token as JWT and fetch user via validateSession
443
+ jwtToken.value = result.session.token;
444
+ if (authState.value) {
445
+ authState.value = { ...authState.value, authMode: 'jwt' };
446
+ }
447
+ console.debug('[Auth] Passkey: Stored session token as JWT');
277
448
  }
278
449
 
279
- return { success: true, user: result.user as BetterAuthUser };
450
+ return { success: true, user: result.user as BetterAuthUser, session: result.session };
280
451
  } catch (err: unknown) {
281
452
  // Handle WebAuthn-specific errors
282
453
  if (err instanceof Error && err.name === 'NotAllowedError') {
@@ -303,16 +474,11 @@ export function useBetterAuth() {
303
474
  isLoading.value = true;
304
475
 
305
476
  try {
306
- // In development, use the Nuxt proxy to ensure cookies are sent correctly
307
- // In production, use the direct API URL
308
- const isDev = import.meta.dev;
309
- const runtimeConfig = useRuntimeConfig();
310
- const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
477
+ const apiBase = getApiBase();
311
478
 
312
479
  // Step 1: Get registration options from server
313
- const optionsResponse = await fetch(`${apiBase}/passkey/generate-register-options`, {
480
+ const optionsResponse = await fetchWithAuth(`${apiBase}/passkey/generate-register-options`, {
314
481
  method: 'GET',
315
- credentials: 'include',
316
482
  });
317
483
 
318
484
  if (!optionsResponse.ok) {
@@ -356,6 +522,8 @@ export function useBetterAuth() {
356
522
  const attestationResponse = credential.response as AuthenticatorAttestationResponse;
357
523
  const credentialBody = {
358
524
  name,
525
+ // Include challengeId for JWT mode (database challenge storage)
526
+ challengeId: options.challengeId,
359
527
  response: {
360
528
  id: credential.id,
361
529
  rawId: arrayBufferToBase64Url(credential.rawId),
@@ -370,9 +538,8 @@ export function useBetterAuth() {
370
538
  };
371
539
 
372
540
  // Step 6: Send to server for verification and storage
373
- const registerResponse = await fetch(`${apiBase}/passkey/verify-registration`, {
541
+ const registerResponse = await fetchWithAuth(`${apiBase}/passkey/verify-registration`, {
374
542
  method: 'POST',
375
- credentials: 'include',
376
543
  headers: { 'Content-Type': 'application/json' },
377
544
  body: JSON.stringify(credentialBody),
378
545
  });
@@ -395,21 +562,36 @@ export function useBetterAuth() {
395
562
  }
396
563
 
397
564
  return {
565
+ // Auth state
566
+ authMode,
567
+ isAuthenticated,
568
+ isJwtMode,
569
+ isLoading: computed(() => isLoading.value),
570
+ user,
571
+
572
+ // User properties
573
+ is2FAEnabled,
574
+ isAdmin,
575
+
576
+ // Auth actions
398
577
  authenticateWithPasskey,
399
578
  changePassword: authClient.changePassword,
400
579
  clearUser,
401
- is2FAEnabled,
402
- isAdmin,
403
- isAuthenticated,
404
- isLoading: computed(() => isLoading.value),
405
- passkey: authClient.passkey,
406
580
  registerPasskey,
407
581
  setUser,
408
582
  signIn,
409
583
  signOut,
410
584
  signUp,
411
- twoFactor: authClient.twoFactor,
412
- user,
413
585
  validateSession,
586
+
587
+ // JWT management
588
+ fetchWithAuth,
589
+ jwtToken,
590
+ refreshJwtToken,
591
+ switchToJwtMode,
592
+
593
+ // Better Auth client passthrough
594
+ passkey: authClient.passkey,
595
+ twoFactor: authClient.twoFactor,
414
596
  };
415
597
  }
@@ -2,6 +2,7 @@ import { passkeyClient } from '@better-auth/passkey/client';
2
2
  import { adminClient, twoFactorClient } from 'better-auth/client/plugins';
3
3
  import { createAuthClient } from 'better-auth/vue';
4
4
 
5
+ import { authFetch } from '~/lib/auth-state';
5
6
  import { sha256 } from '~/utils/crypto';
6
7
 
7
8
  // =============================================================================
@@ -85,18 +86,13 @@ export function createBetterAuthClient(config: AuthClientConfig = {}) {
85
86
  // - Frontend runs on localhost:3002, API on localhost:3000
86
87
  // - WebAuthn validates the origin, which must be consistent
87
88
  // - The Nuxt server proxy ensures requests come from the frontend origin
88
- const isDev = import.meta.env?.DEV || process.env.NODE_ENV === 'development';
89
- const defaultBaseURL = isDev ? '' : (import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000');
89
+ // Note: In Nuxt, use import.meta.dev (not import.meta.env?.DEV which is Vite-specific)
90
+ // At lenne.tech, 'development' is a stage on a web server, 'local' is the local dev environment
91
+ const isDev = import.meta.dev || process.env.NODE_ENV === 'local';
92
+ const defaultBaseURL = isDev ? '' : import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000';
90
93
  const defaultBasePath = isDev ? '/api/iam' : '/iam';
91
94
 
92
- const {
93
- baseURL = defaultBaseURL,
94
- basePath = defaultBasePath,
95
- twoFactorRedirectPath = '/auth/2fa',
96
- enableAdmin = true,
97
- enableTwoFactor = true,
98
- enablePasskey = true,
99
- } = config;
95
+ const { baseURL = defaultBaseURL, basePath = defaultBasePath, twoFactorRedirectPath = '/auth/2fa', enableAdmin = true, enableTwoFactor = true, enablePasskey = true } = config;
100
96
 
101
97
  // Build plugins array based on configuration
102
98
  const plugins: any[] = [];
@@ -120,11 +116,12 @@ export function createBetterAuthClient(config: AuthClientConfig = {}) {
120
116
  }
121
117
 
122
118
  // Create base client with configuration
119
+ // Uses authFetch for automatic Cookie/JWT dual-mode authentication
123
120
  const baseClient = createAuthClient({
124
121
  basePath,
125
122
  baseURL,
126
123
  fetchOptions: {
127
- credentials: 'include', // Required for cross-origin cookie handling
124
+ customFetchImpl: authFetch,
128
125
  },
129
126
  plugins,
130
127
  });