create-nuxt-base 1.0.3 → 1.1.1

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.
@@ -1,25 +1,410 @@
1
1
  import { authClient } from '~/lib/auth-client';
2
+ import { arrayBufferToBase64Url, base64UrlToUint8Array } from '~/utils/crypto';
2
3
 
4
+ /**
5
+ * User type for Better Auth session
6
+ */
7
+ interface BetterAuthUser {
8
+ email: string;
9
+ emailVerified?: boolean;
10
+ id: string;
11
+ name?: string;
12
+ role?: string;
13
+ twoFactorEnabled?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Stored auth state (persisted in cookie for SSR compatibility)
18
+ */
19
+ interface StoredAuthState {
20
+ user: BetterAuthUser | null;
21
+ }
22
+
23
+ /**
24
+ * Result of passkey authentication
25
+ */
26
+ interface PasskeyAuthResult {
27
+ error?: string;
28
+ success: boolean;
29
+ user?: BetterAuthUser;
30
+ }
31
+
32
+ /**
33
+ * Better Auth composable with client-side state management
34
+ *
35
+ * 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
+ *
39
+ * The state is populated after login and cleared on logout.
40
+ */
3
41
  export function useBetterAuth() {
4
- const session = authClient.useSession();
42
+ // Use useCookie for SSR-compatible persistent state
43
+ const authState = useCookie<StoredAuthState>('auth-state', {
44
+ default: () => ({ user: null }),
45
+ maxAge: 60 * 60 * 24 * 7, // 7 days
46
+ sameSite: 'lax',
47
+ });
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
+ });
5
55
 
6
- const user = computed<null | User>(() => (session.value.data?.user as User) ?? null);
56
+ // Loading state
57
+ const isLoading = ref<boolean>(false);
58
+
59
+ // Computed properties based on stored state
60
+ const user = computed<BetterAuthUser | null>(() => authState.value?.user ?? null);
7
61
  const isAuthenticated = computed<boolean>(() => !!user.value);
8
62
  const isAdmin = computed<boolean>(() => user.value?.role === 'admin');
9
63
  const is2FAEnabled = computed<boolean>(() => user.value?.twoFactorEnabled ?? false);
10
- const isLoading = computed<boolean>(() => session.value.isPending);
64
+
65
+ /**
66
+ * Set user data after successful login/signup
67
+ */
68
+ function setUser(userData: BetterAuthUser | null): void {
69
+ authState.value = { user: userData };
70
+ }
71
+
72
+ /**
73
+ * Clear user data on logout
74
+ */
75
+ function clearUser(): void {
76
+ authState.value = { user: null };
77
+ }
78
+
79
+ /**
80
+ * Validate session with backend (called on app init)
81
+ * If session is invalid, clear the stored state
82
+ */
83
+ async function validateSession(): Promise<boolean> {
84
+ try {
85
+ // Try to get session from Better Auth
86
+ const session = authClient.useSession();
87
+
88
+ // Wait for session to load
89
+ if (session.value.isPending) {
90
+ await new Promise((resolve) => {
91
+ const unwatch = watch(
92
+ () => session.value.isPending,
93
+ (isPending) => {
94
+ if (!isPending) {
95
+ unwatch();
96
+ resolve(true);
97
+ }
98
+ },
99
+ { immediate: true },
100
+ );
101
+ });
102
+ }
103
+
104
+ // If session has user data, update our state
105
+ if (session.value.data?.user) {
106
+ setUser(session.value.data.user as BetterAuthUser);
107
+ return true;
108
+ }
109
+
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
116
+ return true;
117
+ }
118
+
119
+ // No valid session found - clear state
120
+ if (authState.value?.user) {
121
+ clearUser();
122
+ }
123
+ return false;
124
+ } catch (error) {
125
+ console.debug('Session validation failed:', error);
126
+ return !!authState.value?.user;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Sign in with email and password
132
+ */
133
+ const signIn = {
134
+ ...authClient.signIn,
135
+ email: async (params: { email: string; password: string; rememberMe?: boolean }, options?: any) => {
136
+ isLoading.value = true;
137
+ try {
138
+ const result = await authClient.signIn.email(params, options);
139
+
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);
145
+ }
146
+
147
+ return result;
148
+ } finally {
149
+ isLoading.value = false;
150
+ }
151
+ },
152
+ };
153
+
154
+ /**
155
+ * Sign up with email and password
156
+ */
157
+ const signUp = {
158
+ ...authClient.signUp,
159
+ email: async (params: { email: string; name: string; password: string }, options?: any) => {
160
+ isLoading.value = true;
161
+ try {
162
+ const result = await authClient.signUp.email(params, options);
163
+
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);
169
+ }
170
+
171
+ return result;
172
+ } finally {
173
+ isLoading.value = false;
174
+ }
175
+ },
176
+ };
177
+
178
+ /**
179
+ * Sign out
180
+ */
181
+ const signOut = async (options?: any) => {
182
+ isLoading.value = true;
183
+ try {
184
+ const result = await authClient.signOut(options);
185
+ // Clear user data on logout
186
+ clearUser();
187
+ return result;
188
+ } finally {
189
+ isLoading.value = false;
190
+ }
191
+ };
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
+ // Direct API access (CORS is configured on the server for trusted origins)
209
+ const runtimeConfig = useRuntimeConfig();
210
+ const apiUrl = runtimeConfig.public.apiUrl || 'http://localhost:3000';
211
+
212
+ // Step 1: Get authentication options from server
213
+ const optionsResponse = await fetch(`${apiUrl}/iam/passkey/generate-authenticate-options`, {
214
+ method: 'GET',
215
+ credentials: 'include',
216
+ });
217
+
218
+ if (!optionsResponse.ok) {
219
+ return { success: false, error: 'Konnte Passkey-Optionen nicht laden' };
220
+ }
221
+
222
+ const options = await optionsResponse.json();
223
+
224
+ // Step 2: Convert challenge from base64url to ArrayBuffer
225
+ const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
226
+
227
+ // Step 3: Get credential from browser's WebAuthn API
228
+ const credential = (await navigator.credentials.get({
229
+ publicKey: {
230
+ challenge: challengeBuffer,
231
+ rpId: options.rpId,
232
+ allowCredentials: options.allowCredentials || [],
233
+ userVerification: options.userVerification,
234
+ timeout: options.timeout,
235
+ },
236
+ })) as PublicKeyCredential | null;
237
+
238
+ if (!credential) {
239
+ return { success: false, error: 'Kein Passkey ausgewählt' };
240
+ }
241
+
242
+ // Step 4: Convert credential response to base64url format
243
+ const response = credential.response as AuthenticatorAssertionResponse;
244
+ const credentialBody = {
245
+ id: credential.id,
246
+ rawId: arrayBufferToBase64Url(credential.rawId),
247
+ type: credential.type,
248
+ response: {
249
+ authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
250
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
251
+ signature: arrayBufferToBase64Url(response.signature),
252
+ userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
253
+ },
254
+ clientExtensionResults: credential.getClientExtensionResults?.() || {},
255
+ };
256
+
257
+ // Step 5: Verify with server
258
+ const authResponse = await fetch(`${apiUrl}/iam/passkey/verify-authentication`, {
259
+ method: 'POST',
260
+ credentials: 'include',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify(credentialBody),
263
+ });
264
+
265
+ const result = await authResponse.json();
266
+
267
+ if (!authResponse.ok) {
268
+ return { success: false, error: result.message || 'Passkey-Anmeldung fehlgeschlagen' };
269
+ }
270
+
271
+ // Store user data after successful passkey login
272
+ if (result.user) {
273
+ setUser(result.user as BetterAuthUser);
274
+ }
275
+
276
+ return { success: true, user: result.user as BetterAuthUser };
277
+ } catch (err: unknown) {
278
+ // Handle WebAuthn-specific errors
279
+ if (err instanceof Error && err.name === 'NotAllowedError') {
280
+ return { success: false, error: 'Passkey-Authentifizierung wurde abgebrochen' };
281
+ }
282
+ return { success: false, error: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen' };
283
+ } finally {
284
+ isLoading.value = false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Register a new passkey for the current user
290
+ *
291
+ * This function handles the complete WebAuthn registration flow:
292
+ * 1. Fetches registration options from the server
293
+ * 2. Prompts the user to create a passkey via the browser's WebAuthn API
294
+ * 3. Sends the credential to the server for storage
295
+ *
296
+ * @param name - Optional name for the passkey
297
+ * @returns Result with success status or error message
298
+ */
299
+ async function registerPasskey(name?: string): Promise<{ success: boolean; error?: string; passkey?: any }> {
300
+ isLoading.value = true;
301
+
302
+ try {
303
+ // Direct API access (CORS is configured on the server for trusted origins)
304
+ const runtimeConfig = useRuntimeConfig();
305
+ const apiUrl = runtimeConfig.public.apiUrl || 'http://localhost:3000';
306
+
307
+ // Step 1: Get registration options from server
308
+ const optionsResponse = await fetch(`${apiUrl}/iam/passkey/generate-register-options`, {
309
+ method: 'GET',
310
+ credentials: 'include',
311
+ });
312
+
313
+ if (!optionsResponse.ok) {
314
+ const error = await optionsResponse.json().catch(() => ({}));
315
+ return { success: false, error: error.message || 'Konnte Registrierungsoptionen nicht laden' };
316
+ }
317
+
318
+ const options = await optionsResponse.json();
319
+
320
+ // Step 2: Convert challenge from base64url to ArrayBuffer
321
+ const challengeBuffer = base64UrlToUint8Array(options.challenge).buffer as ArrayBuffer;
322
+
323
+ // Step 3: Convert user.id from base64url to ArrayBuffer
324
+ const userIdBuffer = base64UrlToUint8Array(options.user.id).buffer as ArrayBuffer;
325
+
326
+ // Step 4: Create credential via browser's WebAuthn API
327
+ const credential = (await navigator.credentials.create({
328
+ publicKey: {
329
+ challenge: challengeBuffer,
330
+ rp: options.rp,
331
+ user: {
332
+ ...options.user,
333
+ id: userIdBuffer,
334
+ },
335
+ pubKeyCredParams: options.pubKeyCredParams,
336
+ timeout: options.timeout,
337
+ attestation: options.attestation,
338
+ authenticatorSelection: options.authenticatorSelection,
339
+ excludeCredentials: (options.excludeCredentials || []).map((cred: any) => ({
340
+ ...cred,
341
+ id: base64UrlToUint8Array(cred.id).buffer as ArrayBuffer,
342
+ })),
343
+ },
344
+ })) as PublicKeyCredential | null;
345
+
346
+ if (!credential) {
347
+ return { success: false, error: 'Passkey-Erstellung abgebrochen' };
348
+ }
349
+
350
+ // Step 5: Convert credential response to base64url format
351
+ const attestationResponse = credential.response as AuthenticatorAttestationResponse;
352
+ const credentialBody = {
353
+ name,
354
+ response: {
355
+ id: credential.id,
356
+ rawId: arrayBufferToBase64Url(credential.rawId),
357
+ type: credential.type,
358
+ response: {
359
+ attestationObject: arrayBufferToBase64Url(attestationResponse.attestationObject),
360
+ clientDataJSON: arrayBufferToBase64Url(attestationResponse.clientDataJSON),
361
+ transports: attestationResponse.getTransports?.() || [],
362
+ },
363
+ clientExtensionResults: credential.getClientExtensionResults?.() || {},
364
+ },
365
+ };
366
+
367
+ // Step 6: Send to server for verification and storage
368
+ const registerResponse = await fetch(`${apiUrl}/iam/passkey/verify-registration`, {
369
+ method: 'POST',
370
+ credentials: 'include',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify(credentialBody),
373
+ });
374
+
375
+ const result = await registerResponse.json();
376
+
377
+ if (!registerResponse.ok) {
378
+ return { success: false, error: result.message || 'Passkey-Registrierung fehlgeschlagen' };
379
+ }
380
+
381
+ return { success: true, passkey: result };
382
+ } catch (err: unknown) {
383
+ if (err instanceof Error && err.name === 'NotAllowedError') {
384
+ return { success: false, error: 'Passkey-Erstellung wurde abgebrochen' };
385
+ }
386
+ return { success: false, error: err instanceof Error ? err.message : 'Passkey-Registrierung fehlgeschlagen' };
387
+ } finally {
388
+ isLoading.value = false;
389
+ }
390
+ }
11
391
 
12
392
  return {
393
+ authenticateWithPasskey,
394
+ changePassword: authClient.changePassword,
395
+ clearUser,
13
396
  is2FAEnabled,
14
397
  isAdmin,
15
398
  isAuthenticated,
16
- isLoading,
399
+ isLoading: computed(() => isLoading.value),
17
400
  passkey: authClient.passkey,
18
- session,
19
- signIn: authClient.signIn,
20
- signOut: authClient.signOut,
21
- signUp: authClient.signUp,
401
+ registerPasskey,
402
+ setUser,
403
+ signIn,
404
+ signOut,
405
+ signUp,
22
406
  twoFactor: authClient.twoFactor,
23
407
  user,
408
+ validateSession,
24
409
  };
25
410
  }