create-nuxt-base 1.1.0 → 1.1.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [1.1.2](https://github.com/lenneTech/nuxt-base-starter/compare/v1.1.1...v1.1.2) (2026-01-22)
6
+
7
+ ### [1.1.1](https://github.com/lenneTech/nuxt-base-starter/compare/v1.1.0...v1.1.1) (2026-01-20)
8
+
9
+
10
+ ### Bug Fixes
11
+
12
+ * **auth:** auto-login after registration and improved passkey handling ([625128b](https://github.com/lenneTech/nuxt-base-starter/commit/625128b18fe812c141859946c196f8efb0738dca))
13
+ * **auth:** improve 2FA UX and document dev-mode proxy requirements ([4c4b1f4](https://github.com/lenneTech/nuxt-base-starter/commit/4c4b1f4d8b77fa93469ccc1a31d4f3292cc7c724))
14
+
5
15
  ## [1.1.0](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.3...v1.1.0) (2026-01-20)
6
16
 
7
17
 
@@ -1,4 +1,5 @@
1
1
  import { authClient } from '~/lib/auth-client';
2
+ import { arrayBufferToBase64Url, base64UrlToUint8Array } from '~/utils/crypto';
2
3
 
3
4
  /**
4
5
  * User type for Better Auth session
@@ -19,6 +20,15 @@ interface StoredAuthState {
19
20
  user: BetterAuthUser | null;
20
21
  }
21
22
 
23
+ /**
24
+ * Result of passkey authentication
25
+ */
26
+ interface PasskeyAuthResult {
27
+ error?: string;
28
+ success: boolean;
29
+ user?: BetterAuthUser;
30
+ }
31
+
22
32
  /**
23
33
  * Better Auth composable with client-side state management
24
34
  *
@@ -36,6 +46,13 @@ export function useBetterAuth() {
36
46
  sameSite: 'lax',
37
47
  });
38
48
 
49
+ // Auth token cookie (for 2FA sessions where no session cookie is set)
50
+ const authToken = useCookie<string | null>('auth-token', {
51
+ default: () => null,
52
+ maxAge: 60 * 60 * 24 * 7, // 7 days
53
+ sameSite: 'lax',
54
+ });
55
+
39
56
  // Loading state
40
57
  const isLoading = ref<boolean>(false);
41
58
 
@@ -173,7 +190,212 @@ export function useBetterAuth() {
173
190
  }
174
191
  };
175
192
 
193
+ /**
194
+ * Authenticate with a passkey (WebAuthn)
195
+ *
196
+ * This function handles the complete WebAuthn authentication flow:
197
+ * 1. Fetches authentication options from the server
198
+ * 2. Prompts the user to select a passkey via the browser's WebAuthn API
199
+ * 3. Sends the signed credential to the server for verification
200
+ * 4. Stores user data on successful authentication
201
+ *
202
+ * @returns Result with success status, user data, or error message
203
+ */
204
+ async function authenticateWithPasskey(): Promise<PasskeyAuthResult> {
205
+ isLoading.value = true;
206
+
207
+ 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`;
213
+
214
+ // Step 1: Get authentication options from server
215
+ const optionsResponse = await fetch(`${apiBase}/passkey/generate-authenticate-options`, {
216
+ method: 'GET',
217
+ credentials: 'include',
218
+ });
219
+
220
+ if (!optionsResponse.ok) {
221
+ return { success: false, error: 'Konnte Passkey-Optionen nicht laden' };
222
+ }
223
+
224
+ const options = await optionsResponse.json();
225
+
226
+ // Step 2: Convert challenge from base64url to ArrayBuffer
227
+ const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
228
+
229
+ // Step 3: Get credential from browser's WebAuthn API
230
+ const credential = (await navigator.credentials.get({
231
+ publicKey: {
232
+ challenge: challengeBuffer,
233
+ rpId: options.rpId,
234
+ allowCredentials: options.allowCredentials || [],
235
+ userVerification: options.userVerification,
236
+ timeout: options.timeout,
237
+ },
238
+ })) as PublicKeyCredential | null;
239
+
240
+ if (!credential) {
241
+ return { success: false, error: 'Kein Passkey ausgewählt' };
242
+ }
243
+
244
+ // Step 4: Convert credential response to base64url format
245
+ const response = credential.response as AuthenticatorAssertionResponse;
246
+ const credentialBody = {
247
+ id: credential.id,
248
+ rawId: arrayBufferToBase64Url(credential.rawId),
249
+ type: credential.type,
250
+ response: {
251
+ authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
252
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
253
+ signature: arrayBufferToBase64Url(response.signature),
254
+ userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
255
+ },
256
+ clientExtensionResults: credential.getClientExtensionResults?.() || {},
257
+ };
258
+
259
+ // Step 5: Verify with server
260
+ // Note: The server expects { response: credentialData } format (matching @simplewebauthn/browser output)
261
+ const authResponse = await fetch(`${apiBase}/passkey/verify-authentication`, {
262
+ method: 'POST',
263
+ credentials: 'include',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ response: credentialBody }),
266
+ });
267
+
268
+ const result = await authResponse.json();
269
+
270
+ if (!authResponse.ok) {
271
+ return { success: false, error: result.message || 'Passkey-Anmeldung fehlgeschlagen' };
272
+ }
273
+
274
+ // Store user data after successful passkey login
275
+ if (result.user) {
276
+ setUser(result.user as BetterAuthUser);
277
+ }
278
+
279
+ return { success: true, user: result.user as BetterAuthUser };
280
+ } catch (err: unknown) {
281
+ // Handle WebAuthn-specific errors
282
+ if (err instanceof Error && err.name === 'NotAllowedError') {
283
+ return { success: false, error: 'Passkey-Authentifizierung wurde abgebrochen' };
284
+ }
285
+ return { success: false, error: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen' };
286
+ } finally {
287
+ isLoading.value = false;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Register a new passkey for the current user
293
+ *
294
+ * This function handles the complete WebAuthn registration flow:
295
+ * 1. Fetches registration options from the server
296
+ * 2. Prompts the user to create a passkey via the browser's WebAuthn API
297
+ * 3. Sends the credential to the server for storage
298
+ *
299
+ * @param name - Optional name for the passkey
300
+ * @returns Result with success status or error message
301
+ */
302
+ async function registerPasskey(name?: string): Promise<{ success: boolean; error?: string; passkey?: any }> {
303
+ isLoading.value = true;
304
+
305
+ 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`;
311
+
312
+ // Step 1: Get registration options from server
313
+ const optionsResponse = await fetch(`${apiBase}/passkey/generate-register-options`, {
314
+ method: 'GET',
315
+ credentials: 'include',
316
+ });
317
+
318
+ if (!optionsResponse.ok) {
319
+ const error = await optionsResponse.json().catch(() => ({}));
320
+ return { success: false, error: error.message || 'Konnte Registrierungsoptionen nicht laden' };
321
+ }
322
+
323
+ const options = await optionsResponse.json();
324
+
325
+ // Step 2: Convert challenge from base64url to ArrayBuffer
326
+ const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
327
+
328
+ // Step 3: Convert user.id from base64url to ArrayBuffer
329
+ const userIdBuffer = base64UrlToUint8Array(options.user.id).buffer as ArrayBuffer;
330
+
331
+ // Step 4: Create credential via browser's WebAuthn API
332
+ const credential = (await navigator.credentials.create({
333
+ publicKey: {
334
+ challenge: challengeBuffer,
335
+ rp: options.rp,
336
+ user: {
337
+ ...options.user,
338
+ id: userIdBuffer,
339
+ },
340
+ pubKeyCredParams: options.pubKeyCredParams,
341
+ timeout: options.timeout,
342
+ attestation: options.attestation,
343
+ authenticatorSelection: options.authenticatorSelection,
344
+ excludeCredentials: (options.excludeCredentials || []).map((cred: any) => ({
345
+ ...cred,
346
+ id: base64UrlToUint8Array(cred.id).buffer as ArrayBuffer,
347
+ })),
348
+ },
349
+ })) as PublicKeyCredential | null;
350
+
351
+ if (!credential) {
352
+ return { success: false, error: 'Passkey-Erstellung abgebrochen' };
353
+ }
354
+
355
+ // Step 5: Convert credential response to base64url format
356
+ const attestationResponse = credential.response as AuthenticatorAttestationResponse;
357
+ const credentialBody = {
358
+ name,
359
+ response: {
360
+ id: credential.id,
361
+ rawId: arrayBufferToBase64Url(credential.rawId),
362
+ type: credential.type,
363
+ response: {
364
+ attestationObject: arrayBufferToBase64Url(attestationResponse.attestationObject),
365
+ clientDataJSON: arrayBufferToBase64Url(attestationResponse.clientDataJSON),
366
+ transports: attestationResponse.getTransports?.() || [],
367
+ },
368
+ clientExtensionResults: credential.getClientExtensionResults?.() || {},
369
+ },
370
+ };
371
+
372
+ // Step 6: Send to server for verification and storage
373
+ const registerResponse = await fetch(`${apiBase}/passkey/verify-registration`, {
374
+ method: 'POST',
375
+ credentials: 'include',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify(credentialBody),
378
+ });
379
+
380
+ const result = await registerResponse.json();
381
+
382
+ if (!registerResponse.ok) {
383
+ return { success: false, error: result.message || 'Passkey-Registrierung fehlgeschlagen' };
384
+ }
385
+
386
+ return { success: true, passkey: result };
387
+ } catch (err: unknown) {
388
+ if (err instanceof Error && err.name === 'NotAllowedError') {
389
+ return { success: false, error: 'Passkey-Erstellung wurde abgebrochen' };
390
+ }
391
+ return { success: false, error: err instanceof Error ? err.message : 'Passkey-Registrierung fehlgeschlagen' };
392
+ } finally {
393
+ isLoading.value = false;
394
+ }
395
+ }
396
+
176
397
  return {
398
+ authenticateWithPasskey,
177
399
  changePassword: authClient.changePassword,
178
400
  clearUser,
179
401
  is2FAEnabled,
@@ -181,6 +403,7 @@ export function useBetterAuth() {
181
403
  isAuthenticated,
182
404
  isLoading: computed(() => isLoading.value),
183
405
  passkey: authClient.passkey,
406
+ registerPasskey,
184
407
  setUser,
185
408
  signIn,
186
409
  signOut,
@@ -1,6 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import type { NavigationMenuItem } from '@nuxt/ui';
3
3
 
4
+ const { isAuthenticated, signOut, user } = useBetterAuth();
5
+
6
+ async function handleLogout() {
7
+ await signOut();
8
+ await navigateTo('/auth/login');
9
+ }
10
+
4
11
  const headerItems = computed<NavigationMenuItem[]>(() => [
5
12
  {
6
13
  label: 'Docs',
@@ -51,6 +58,16 @@ const footerItems: NavigationMenuItem[] = [
51
58
  <UNavigationMenu :items="headerItems" />
52
59
 
53
60
  <template #right>
61
+ <template v-if="isAuthenticated">
62
+ <span class="text-sm text-muted hidden sm:inline">{{ user?.email }}</span>
63
+ <UTooltip text="Logout">
64
+ <UButton color="neutral" variant="ghost" icon="i-lucide-log-out" aria-label="Logout" @click="handleLogout" />
65
+ </UTooltip>
66
+ </template>
67
+ <template v-else>
68
+ <UButton color="primary" variant="soft" to="/auth/login" icon="i-lucide-log-in" label="Login" />
69
+ </template>
70
+
54
71
  <UColorModeButton />
55
72
 
56
73
  <UTooltip text="Open on GitHub" :kbds="['meta', 'G']">
@@ -80,9 +80,18 @@ export interface AuthClientConfig {
80
80
  * plain text password transmission over the network.
81
81
  */
82
82
  export function createBetterAuthClient(config: AuthClientConfig = {}) {
83
+ // In development, use empty baseURL and /api/iam path to leverage Nuxt server proxy
84
+ // This is REQUIRED for WebAuthn/Passkey to work correctly because:
85
+ // - Frontend runs on localhost:3002, API on localhost:3000
86
+ // - WebAuthn validates the origin, which must be consistent
87
+ // - 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');
90
+ const defaultBasePath = isDev ? '/api/iam' : '/iam';
91
+
83
92
  const {
84
- baseURL = import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000',
85
- basePath = '/iam',
93
+ baseURL = defaultBaseURL,
94
+ basePath = defaultBasePath,
86
95
  twoFactorRedirectPath = '/auth/2fa',
87
96
  enableAdmin = true,
88
97
  enableTwoFactor = true,
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ // ============================================================================
3
+ // Composables
4
+ // ============================================================================
5
+ const { user, signOut } = useBetterAuth();
6
+
7
+ // ============================================================================
8
+ // Variables
9
+ // ============================================================================
10
+ const pages = [
11
+ {
12
+ title: 'Sicherheit',
13
+ description: 'Verwalte 2FA, Passkeys und Kontosicherheit',
14
+ icon: 'i-lucide-shield-check',
15
+ to: '/app/settings/security',
16
+ },
17
+ ];
18
+
19
+ // ============================================================================
20
+ // Functions
21
+ // ============================================================================
22
+ async function handleSignOut(): Promise<void> {
23
+ await signOut();
24
+ await navigateTo('/auth/login');
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div class="mx-auto max-w-4xl px-4 py-8">
30
+ <!-- Welcome Header -->
31
+ <div class="mb-8">
32
+ <h1 class="text-3xl font-bold">
33
+ Willkommen{{ user?.name ? `, ${user.name}` : '' }}!
34
+ </h1>
35
+ <p class="mt-2 text-muted">
36
+ {{ user?.email }}
37
+ </p>
38
+ </div>
39
+
40
+ <!-- Quick Actions -->
41
+ <div class="mb-8">
42
+ <h2 class="mb-4 text-xl font-semibold">Schnellzugriff</h2>
43
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
44
+ <UCard
45
+ v-for="page in pages"
46
+ :key="page.to"
47
+ class="cursor-pointer transition-shadow hover:shadow-lg"
48
+ @click="navigateTo(page.to)"
49
+ >
50
+ <div class="flex items-start gap-4">
51
+ <div class="rounded-lg bg-primary/10 p-3">
52
+ <UIcon :name="page.icon" class="size-6 text-primary" />
53
+ </div>
54
+ <div>
55
+ <h3 class="font-semibold">{{ page.title }}</h3>
56
+ <p class="mt-1 text-sm text-muted">{{ page.description }}</p>
57
+ </div>
58
+ </div>
59
+ </UCard>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- User Info Card -->
64
+ <UCard>
65
+ <template #header>
66
+ <div class="flex items-center gap-3">
67
+ <UIcon name="i-lucide-user" class="size-6 text-primary" />
68
+ <h2 class="font-semibold">Dein Konto</h2>
69
+ </div>
70
+ </template>
71
+
72
+ <div class="space-y-3">
73
+ <div class="flex justify-between">
74
+ <span class="text-muted">Name</span>
75
+ <span class="font-medium">{{ user?.name || '-' }}</span>
76
+ </div>
77
+ <div class="flex justify-between">
78
+ <span class="text-muted">E-Mail</span>
79
+ <span class="font-medium">{{ user?.email || '-' }}</span>
80
+ </div>
81
+ <div class="flex justify-between">
82
+ <span class="text-muted">E-Mail verifiziert</span>
83
+ <UBadge :color="user?.emailVerified ? 'success' : 'warning'">
84
+ {{ user?.emailVerified ? 'Ja' : 'Nein' }}
85
+ </UBadge>
86
+ </div>
87
+ </div>
88
+
89
+ <template #footer>
90
+ <UButton
91
+ color="error"
92
+ variant="outline"
93
+ icon="i-lucide-log-out"
94
+ @click="handleSignOut"
95
+ >
96
+ Abmelden
97
+ </UButton>
98
+ </template>
99
+ </UCard>
100
+ </div>
101
+ </template>
@@ -24,7 +24,7 @@ interface Passkey {
24
24
  // ============================================================================
25
25
  const toast = useToast();
26
26
  const overlay = useOverlay();
27
- const { is2FAEnabled, user } = useBetterAuth();
27
+ const { is2FAEnabled, registerPasskey, setUser, user } = useBetterAuth();
28
28
 
29
29
  // ============================================================================
30
30
  // Variables
@@ -39,6 +39,11 @@ const passkeyLoading = ref<boolean>(false);
39
39
  const newPasskeyName = ref<string>('');
40
40
  const showAddPasskey = ref<boolean>(false);
41
41
 
42
+ // Form states for UForm (required for proper data binding)
43
+ const enable2FAForm = reactive({ password: '' });
44
+ const disable2FAForm = reactive({ password: '' });
45
+ const totpForm = reactive({ code: '' });
46
+
42
47
  const passwordSchema = v.object({
43
48
  password: v.pipe(v.string('Passwort ist erforderlich'), v.minLength(1, 'Passwort ist erforderlich')),
44
49
  });
@@ -70,14 +75,13 @@ async function addPasskey(): Promise<void> {
70
75
  passkeyLoading.value = true;
71
76
 
72
77
  try {
73
- const { error } = await authClient.passkey.addPasskey({
74
- name: newPasskeyName.value,
75
- });
78
+ // Use custom registerPasskey method with direct API calls
79
+ const result = await registerPasskey(newPasskeyName.value);
76
80
 
77
- if (error) {
81
+ if (!result.success) {
78
82
  toast.add({
79
83
  color: 'error',
80
- description: error.message || 'Passkey konnte nicht hinzugefügt werden',
84
+ description: result.error || 'Passkey konnte nicht hinzugefügt werden',
81
85
  title: 'Fehler',
82
86
  });
83
87
  return;
@@ -143,6 +147,11 @@ async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<voi
143
147
  return;
144
148
  }
145
149
 
150
+ // Update user state to reflect 2FA disabled
151
+ if (user.value) {
152
+ setUser({ ...user.value, twoFactorEnabled: false });
153
+ }
154
+
146
155
  toast.add({
147
156
  color: 'success',
148
157
  description: '2FA wurde deaktiviert',
@@ -150,6 +159,7 @@ async function disable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<voi
150
159
  });
151
160
 
152
161
  show2FADisable.value = false;
162
+ disable2FAForm.password = '';
153
163
  } finally {
154
164
  loading.value = false;
155
165
  }
@@ -175,6 +185,7 @@ async function enable2FA(payload: FormSubmitEvent<PasswordSchema>): Promise<void
175
185
  totpUri.value = data?.totpURI ?? '';
176
186
  backupCodes.value = data?.backupCodes ?? [];
177
187
  showTotpSetup.value = true;
188
+ enable2FAForm.password = '';
178
189
  } finally {
179
190
  loading.value = false;
180
191
  }
@@ -224,6 +235,11 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
224
235
  return;
225
236
  }
226
237
 
238
+ // Update user state to reflect 2FA enabled
239
+ if (user.value) {
240
+ setUser({ ...user.value, twoFactorEnabled: true });
241
+ }
242
+
227
243
  toast.add({
228
244
  color: 'success',
229
245
  description: '2FA wurde erfolgreich aktiviert',
@@ -231,6 +247,7 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
231
247
  });
232
248
 
233
249
  showTotpSetup.value = false;
250
+ totpForm.code = '';
234
251
  await openBackupCodesModal(backupCodes.value);
235
252
  } finally {
236
253
  loading.value = false;
@@ -271,9 +288,9 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
271
288
  </div>
272
289
 
273
290
  <template v-if="!is2FAEnabled && !showTotpSetup">
274
- <UForm :schema="passwordSchema" class="space-y-4" @submit="enable2FA">
291
+ <UForm :schema="passwordSchema" :state="enable2FAForm" class="space-y-4" @submit="enable2FA">
275
292
  <UFormField label="Passwort bestätigen" name="password">
276
- <UInput name="password" type="password" placeholder="Dein Passwort" />
293
+ <UInput v-model="enable2FAForm.password" type="password" placeholder="Dein Passwort" />
277
294
  </UFormField>
278
295
  <UButton type="submit" :loading="loading"> 2FA aktivieren </UButton>
279
296
  </UForm>
@@ -281,15 +298,15 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
281
298
 
282
299
  <template v-if="showTotpSetup">
283
300
  <div class="space-y-4">
284
- <UAlert color="info" icon="i-lucide-info"> Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy) und gib den Code ein. </UAlert>
301
+ <p class="text-sm text-muted">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy) und gib den Code ein.</p>
285
302
 
286
303
  <div class="flex justify-center">
287
304
  <img v-if="totpUri" :src="`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`" alt="TOTP QR Code" class="rounded-lg" />
288
305
  </div>
289
306
 
290
- <UForm :schema="totpSchema" class="space-y-4" @submit="verifyTotp">
307
+ <UForm :schema="totpSchema" :state="totpForm" class="space-y-4" @submit="verifyTotp">
291
308
  <UFormField label="Verifizierungscode" name="code">
292
- <UInput name="code" placeholder="000000" class="text-center font-mono" />
309
+ <UInput v-model="totpForm.code" placeholder="000000" class="text-center font-mono" />
293
310
  </UFormField>
294
311
  <div class="flex gap-2">
295
312
  <UButton type="submit" :loading="loading"> Verifizieren </UButton>
@@ -307,10 +324,10 @@ async function verifyTotp(payload: FormSubmitEvent<TotpSchema>): Promise<void> {
307
324
  </template>
308
325
 
309
326
  <template v-if="show2FADisable">
310
- <UForm :schema="passwordSchema" class="space-y-4" @submit="disable2FA">
327
+ <UForm :schema="passwordSchema" :state="disable2FAForm" class="space-y-4" @submit="disable2FA">
311
328
  <UAlert color="warning" icon="i-lucide-alert-triangle"> 2FA zu deaktivieren verringert die Sicherheit deines Kontos. </UAlert>
312
329
  <UFormField label="Passwort bestätigen" name="password">
313
- <UInput name="password" type="password" placeholder="Dein Passwort" />
330
+ <UInput v-model="disable2FAForm.password" type="password" placeholder="Dein Passwort" />
314
331
  </UFormField>
315
332
  <div class="flex gap-2">
316
333
  <UButton type="submit" color="error" :loading="loading"> 2FA deaktivieren </UButton>
@@ -127,8 +127,13 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
127
127
  }
128
128
 
129
129
  // Check if 2FA is required
130
+ // Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
130
131
  const resultData = 'data' in result ? result.data : result;
131
- if (resultData && 'twoFactorRedirect' in resultData && resultData.twoFactorRedirect) {
132
+ const requires2FA = resultData && (
133
+ ('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) ||
134
+ ('requiresTwoFactor' in resultData && resultData.requiresTwoFactor)
135
+ );
136
+ if (requires2FA) {
132
137
  // Redirect to 2FA page
133
138
  await navigateTo('/auth/2fa');
134
139
  return;
@@ -13,6 +13,7 @@ import { authClient } from '~/lib/auth-client';
13
13
  // Composables
14
14
  // ============================================================================
15
15
  const toast = useToast();
16
+ const { signUp, signIn } = useBetterAuth();
16
17
 
17
18
  // ============================================================================
18
19
  // Page Meta
@@ -74,66 +75,91 @@ const schema = v.pipe(
74
75
 
75
76
  type Schema = InferOutput<typeof schema>;
76
77
 
77
- async function addPasskey(): Promise<void> {
78
- passkeyLoading.value = true;
78
+ // ============================================================================
79
+ // Functions
80
+ // ============================================================================
81
+ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
82
+ loading.value = true;
79
83
 
80
84
  try {
81
- const { error } = await authClient.passkey.addPasskey({
82
- name: 'Mein Gerät',
85
+ // Step 1: Sign up
86
+ const signUpResult = await signUp.email({
87
+ email: payload.data.email,
88
+ name: payload.data.name,
89
+ password: payload.data.password,
83
90
  });
84
91
 
85
- if (error) {
92
+ const signUpError = 'error' in signUpResult ? signUpResult.error : null;
93
+
94
+ if (signUpError) {
86
95
  toast.add({
87
96
  color: 'error',
88
- description: error.message || 'Passkey konnte nicht hinzugefügt werden',
97
+ description: signUpError.message || 'Registrierung fehlgeschlagen',
89
98
  title: 'Fehler',
90
99
  });
91
100
  return;
92
101
  }
93
102
 
103
+ // Step 2: Sign in to create session (required for passkey registration)
104
+ const signInResult = await signIn.email({
105
+ email: payload.data.email,
106
+ password: payload.data.password,
107
+ });
108
+
109
+ const signInError = 'error' in signInResult ? signInResult.error : null;
110
+
111
+ if (signInError) {
112
+ // Sign-up was successful but sign-in failed - still show success and redirect
113
+ toast.add({
114
+ color: 'success',
115
+ description: 'Dein Konto wurde erstellt. Bitte melde dich an.',
116
+ title: 'Willkommen!',
117
+ });
118
+ await navigateTo('/auth/login');
119
+ return;
120
+ }
121
+
94
122
  toast.add({
95
123
  color: 'success',
96
- description: 'Passkey wurde erfolgreich hinzugefügt',
97
- title: 'Erfolg',
124
+ description: 'Dein Konto wurde erfolgreich erstellt',
125
+ title: 'Willkommen!',
98
126
  });
99
127
 
100
- await navigateTo('/app');
128
+ // Show passkey prompt after successful registration + login
129
+ showPasskeyPrompt.value = true;
101
130
  } finally {
102
- passkeyLoading.value = false;
131
+ loading.value = false;
103
132
  }
104
133
  }
105
134
 
106
- // ============================================================================
107
- // Functions
108
- // ============================================================================
109
- async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
110
- loading.value = true;
135
+ async function addPasskey(): Promise<void> {
136
+ passkeyLoading.value = true;
111
137
 
112
138
  try {
113
- const { error } = await authClient.signUp.email({
114
- email: payload.data.email,
115
- name: payload.data.name,
116
- password: payload.data.password,
139
+ const { error } = await authClient.passkey.addPasskey({
140
+ name: 'Mein Gerät',
117
141
  });
118
142
 
119
143
  if (error) {
120
144
  toast.add({
121
145
  color: 'error',
122
- description: error.message || 'Registrierung fehlgeschlagen',
146
+ description: error.message || 'Passkey konnte nicht hinzugefügt werden',
123
147
  title: 'Fehler',
124
148
  });
149
+ // Still navigate to app even if passkey failed
150
+ await navigateTo('/app');
125
151
  return;
126
152
  }
127
153
 
128
154
  toast.add({
129
155
  color: 'success',
130
- description: 'Dein Konto wurde erfolgreich erstellt',
131
- title: 'Willkommen!',
156
+ description: 'Passkey wurde erfolgreich hinzugefügt',
157
+ title: 'Erfolg',
132
158
  });
133
159
 
134
- showPasskeyPrompt.value = true;
160
+ await navigateTo('/app');
135
161
  } finally {
136
- loading.value = false;
162
+ passkeyLoading.value = false;
137
163
  }
138
164
  }
139
165
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Auth Interceptor Plugin
3
+ *
4
+ * This plugin intercepts all API responses and handles session expiration.
5
+ * When a 401 (Unauthorized) response is received, it automatically:
6
+ * 1. Clears the user session state
7
+ * 2. Redirects to the login page
8
+ *
9
+ * Note: This is a client-only plugin (.client.ts) since auth state
10
+ * management only makes sense in the browser context.
11
+ */
12
+ export default defineNuxtPlugin(() => {
13
+ const { clearUser, isAuthenticated } = useBetterAuth();
14
+ const route = useRoute();
15
+
16
+ // Track if we're already handling a 401 to prevent multiple redirects
17
+ let isHandling401 = false;
18
+
19
+ // Paths that should not trigger auto-logout on 401
20
+ // (public auth endpoints where 401 is expected)
21
+ const publicAuthPaths = [
22
+ '/auth/login',
23
+ '/auth/register',
24
+ '/auth/forgot-password',
25
+ '/auth/reset-password',
26
+ '/auth/2fa',
27
+ ];
28
+
29
+ /**
30
+ * Check if current route is a public auth route
31
+ */
32
+ function isPublicAuthRoute(): boolean {
33
+ return publicAuthPaths.some((path) => route.path.startsWith(path));
34
+ }
35
+
36
+ /**
37
+ * Check if URL is an auth-related endpoint that shouldn't trigger logout
38
+ * (e.g., login, register, password reset endpoints)
39
+ */
40
+ function isAuthEndpoint(url: string): boolean {
41
+ const authEndpoints = [
42
+ '/sign-in',
43
+ '/sign-up',
44
+ '/sign-out',
45
+ '/forgot-password',
46
+ '/reset-password',
47
+ '/verify-email',
48
+ '/session',
49
+ ];
50
+ return authEndpoints.some((endpoint) => url.includes(endpoint));
51
+ }
52
+
53
+ /**
54
+ * Handle 401 Unauthorized responses
55
+ * Clears user state and redirects to login page
56
+ */
57
+ async function handleUnauthorized(requestUrl?: string): Promise<void> {
58
+ // Prevent multiple simultaneous 401 handling
59
+ if (isHandling401) {
60
+ return;
61
+ }
62
+
63
+ // Don't handle 401 for auth endpoints (expected behavior)
64
+ if (requestUrl && isAuthEndpoint(requestUrl)) {
65
+ return;
66
+ }
67
+
68
+ // Don't handle 401 on public auth pages
69
+ if (isPublicAuthRoute()) {
70
+ return;
71
+ }
72
+
73
+ isHandling401 = true;
74
+
75
+ try {
76
+ // Only handle if user was authenticated (prevents redirect loops)
77
+ if (isAuthenticated.value) {
78
+ console.debug('[Auth Interceptor] Session expired, logging out...');
79
+
80
+ // Clear user state
81
+ clearUser();
82
+
83
+ // Redirect to login page with return URL
84
+ await navigateTo({
85
+ path: '/auth/login',
86
+ query: {
87
+ redirect: route.fullPath !== '/auth/login' ? route.fullPath : undefined,
88
+ },
89
+ }, {
90
+ replace: true,
91
+ });
92
+ }
93
+ } finally {
94
+ // Reset flag after a short delay to allow navigation to complete
95
+ setTimeout(() => {
96
+ isHandling401 = false;
97
+ }, 1000);
98
+ }
99
+ }
100
+
101
+ // Override the default $fetch to add response error handling
102
+ const originalFetch = globalThis.$fetch;
103
+
104
+ // Use a wrapper to intercept responses
105
+ globalThis.$fetch = ((url: string, options?: any) => {
106
+ return originalFetch(url, {
107
+ ...options,
108
+ onResponseError: (context: any) => {
109
+ // Call original onResponseError if provided
110
+ if (options?.onResponseError) {
111
+ options.onResponseError(context);
112
+ }
113
+
114
+ // Handle 401 errors
115
+ if (context.response?.status === 401) {
116
+ handleUnauthorized(url);
117
+ }
118
+ },
119
+ });
120
+ }) as typeof globalThis.$fetch;
121
+
122
+ // Also intercept native fetch for manual API calls
123
+ const originalNativeFetch = globalThis.fetch;
124
+
125
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
126
+ const response = await originalNativeFetch(input, init);
127
+
128
+ // Handle 401 errors from native fetch
129
+ if (response.status === 401) {
130
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
131
+ handleUnauthorized(url);
132
+ }
133
+
134
+ return response;
135
+ };
136
+
137
+ // Provide a manual method to trigger logout on 401
138
+ return {
139
+ provide: {
140
+ handleUnauthorized,
141
+ },
142
+ };
143
+ });
@@ -11,3 +11,34 @@ export async function sha256(message: string): Promise<string> {
11
11
  const hashArray = Array.from(new Uint8Array(hashBuffer));
12
12
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
13
13
  }
14
+
15
+ // ============================================================================
16
+ // WebAuthn/Passkey Utilities
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Converts an ArrayBuffer to a base64url-encoded string
21
+ * Used for WebAuthn credential responses
22
+ *
23
+ * @param buffer - The ArrayBuffer to convert
24
+ * @returns The base64url-encoded string (no padding)
25
+ */
26
+ export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
27
+ const bytes = new Uint8Array(buffer);
28
+ let binary = '';
29
+ bytes.forEach((b) => (binary += String.fromCharCode(b)));
30
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
31
+ }
32
+
33
+ /**
34
+ * Converts a base64url-encoded string to a Uint8Array
35
+ * Used for WebAuthn challenge decoding
36
+ *
37
+ * @param base64url - The base64url-encoded string
38
+ * @returns The decoded Uint8Array
39
+ */
40
+ export function base64UrlToUint8Array(base64url: string): Uint8Array {
41
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
42
+ const paddedBase64 = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
43
+ return Uint8Array.from(atob(paddedBase64), (c) => c.charCodeAt(0));
44
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Proxy handler for Better Auth API requests
3
+ *
4
+ * This proxy is REQUIRED for WebAuthn/Passkey functionality in development mode because:
5
+ * 1. Frontend runs on a different port (e.g., 3001/3002) than the API (3000)
6
+ * 2. WebAuthn validates the origin, which must be consistent
7
+ * 3. Cross-origin requests have cookie handling issues
8
+ *
9
+ * The proxy ensures:
10
+ * - Cookies are properly forwarded between frontend and API
11
+ * - The origin header is preserved for WebAuthn validation
12
+ * - Set-Cookie headers from the API are forwarded to the browser
13
+ */
14
+ export default defineEventHandler(async (event) => {
15
+ const path = getRouterParam(event, 'path') || '';
16
+ const apiUrl = process.env.API_URL || 'http://localhost:3000';
17
+ const targetUrl = `${apiUrl}/iam/${path}`;
18
+
19
+ // Get query string
20
+ const query = getQuery(event);
21
+ const queryString = new URLSearchParams(query as Record<string, string>).toString();
22
+ const fullUrl = queryString ? `${targetUrl}?${queryString}` : targetUrl;
23
+
24
+ // Get request body for POST/PUT/PATCH requests
25
+ const method = event.method;
26
+ let body: any;
27
+ if (['POST', 'PUT', 'PATCH'].includes(method)) {
28
+ body = await readBody(event);
29
+ }
30
+
31
+ // Forward cookies from the incoming request
32
+ const cookieHeader = getHeader(event, 'cookie');
33
+ // Use the actual origin from the request, fallback to localhost
34
+ const originHeader = getHeader(event, 'origin') || getHeader(event, 'referer')?.replace(/\/[^/]*$/, '') || 'http://localhost:3001';
35
+
36
+ // Make the request to the API
37
+ const response = await $fetch.raw(fullUrl, {
38
+ method: method as any,
39
+ body: body ? JSON.stringify(body) : undefined,
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ 'Origin': originHeader,
43
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
44
+ },
45
+ credentials: 'include',
46
+ // Don't throw on error status codes
47
+ ignoreResponseError: true,
48
+ });
49
+
50
+ // Forward Set-Cookie headers from the API response
51
+ const setCookieHeaders = response.headers.getSetCookie?.() || [];
52
+ for (const cookie of setCookieHeaders) {
53
+ // Rewrite cookie to work on localhost (remove domain/port specifics)
54
+ const rewrittenCookie = cookie
55
+ .replace(/domain=[^;]+;?\s*/gi, '')
56
+ .replace(/secure;?\s*/gi, '');
57
+ appendResponseHeader(event, 'Set-Cookie', rewrittenCookie);
58
+ }
59
+
60
+ // Set response status
61
+ setResponseStatus(event, response.status);
62
+
63
+ // Return the response body
64
+ return response._data;
65
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nuxt-base",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Starter to generate a configured environment with VueJS, Nuxt, Tailwind, Eslint, Unit Tests, Playwright etc.",
5
5
  "license": "MIT",
6
6
  "repository": {