create-nuxt-base 1.0.3 → 1.1.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/AUTH.md ADDED
@@ -0,0 +1,289 @@
1
+ # Better Auth Integration
2
+
3
+ This document describes the Better Auth integration in the nuxt-base-starter template.
4
+
5
+ ## Overview
6
+
7
+ The template uses [Better Auth](https://www.better-auth.com/) for authentication with the following features:
8
+
9
+ | Feature | Status | Description |
10
+ |-----------------------|--------|----------------------------------------|
11
+ | Email & Password | ✅ | Standard email/password authentication |
12
+ | Two-Factor Auth (2FA) | ✅ | TOTP-based 2FA with backup codes |
13
+ | Passkey (WebAuthn) | ✅ | Passwordless authentication |
14
+ | Session Management | ✅ | Cookie-based sessions with SSR support |
15
+ | Password Hashing | ✅ | Client-side SHA256 hashing |
16
+
17
+ ## Architecture
18
+
19
+ ```
20
+ ┌─────────────────────────────────────────────────────────────────┐
21
+ │ FRONTEND (Nuxt) │
22
+ ├─────────────────────────────────────────────────────────────────┤
23
+ │ │
24
+ │ ┌─────────────────┐ ┌─────────────────┐ │
25
+ │ │ auth-client │───▶│ useBetterAuth │ │
26
+ │ │ (lib/) │ │ (composable) │ │
27
+ │ └────────┬────────┘ └────────┬────────┘ │
28
+ │ │ │ │
29
+ │ │ SHA256 Hashing │ Cookie-based State │
30
+ │ │ Plugin Config │ Session Validation │
31
+ │ │ │ │
32
+ └───────────┼──────────────────────┼──────────────────────────────┘
33
+ │ │
34
+ ▼ ▼
35
+ ┌─────────────────────────────────────────────────────────────────┐
36
+ │ BACKEND (nest-server) │
37
+ ├─────────────────────────────────────────────────────────────────┤
38
+ │ /iam/sign-in/email /iam/session │
39
+ │ /iam/sign-up/email /iam/sign-out │
40
+ │ /iam/passkey/* /iam/two-factor/* │
41
+ └─────────────────────────────────────────────────────────────────┘
42
+ ```
43
+
44
+ ## Files
45
+
46
+ | File | Purpose |
47
+ |--------------------------------------|----------------------------------|
48
+ | `app/lib/auth-client.ts` | Better Auth client configuration |
49
+ | `app/composables/use-better-auth.ts` | Auth state management composable |
50
+ | `app/pages/auth/login.vue` | Login page |
51
+ | `app/pages/auth/register.vue` | Registration page |
52
+ | `app/pages/auth/2fa.vue` | Two-factor authentication page |
53
+ | `app/pages/auth/forgot-password.vue` | Password reset request |
54
+ | `app/pages/auth/reset-password.vue` | Password reset form |
55
+ | `app/utils/crypto.ts` | SHA256 hashing utility |
56
+
57
+ ## Usage
58
+
59
+ ### Basic Authentication
60
+
61
+ ```typescript
62
+ // In a Vue component
63
+ const { signIn, signUp, signOut, user, isAuthenticated } = useBetterAuth();
64
+
65
+ // Sign in
66
+ const result = await signIn.email({
67
+ email: 'user@example.com',
68
+ password: 'password123',
69
+ });
70
+
71
+ // Sign up
72
+ const result = await signUp.email({
73
+ email: 'user@example.com',
74
+ name: 'John Doe',
75
+ password: 'password123',
76
+ });
77
+
78
+ // Sign out
79
+ await signOut();
80
+
81
+ // Check auth state
82
+ if (isAuthenticated.value) {
83
+ console.log('User:', user.value);
84
+ }
85
+ ```
86
+
87
+ ### Passkey Authentication
88
+
89
+ ```typescript
90
+ import { authClient } from '~/lib/auth-client';
91
+
92
+ // Sign in with passkey
93
+ const result = await authClient.signIn.passkey();
94
+
95
+ if (result.error) {
96
+ console.error('Passkey login failed:', result.error.message);
97
+ } else {
98
+ // Validate session to get user data (passkey returns session only)
99
+ await validateSession();
100
+ navigateTo('/app');
101
+ }
102
+ ```
103
+
104
+ ### Two-Factor Authentication
105
+
106
+ ```typescript
107
+ import { authClient } from '~/lib/auth-client';
108
+
109
+ // Verify TOTP code
110
+ const result = await authClient.twoFactor.verifyTotp({
111
+ code: '123456',
112
+ trustDevice: true,
113
+ });
114
+
115
+ // Verify backup code
116
+ const result = await authClient.twoFactor.verifyBackupCode({
117
+ code: 'backup-code-here',
118
+ });
119
+ ```
120
+
121
+ ### Session Validation
122
+
123
+ ```typescript
124
+ const { validateSession, user } = useBetterAuth();
125
+
126
+ // On app init, validate the session
127
+ const isValid = await validateSession();
128
+
129
+ if (isValid) {
130
+ console.log('Session valid, user:', user.value);
131
+ } else {
132
+ console.log('No valid session');
133
+ }
134
+ ```
135
+
136
+ ## Configuration
137
+
138
+ ### Environment Variables
139
+
140
+ ```env
141
+ # API URL (required)
142
+ API_URL=http://localhost:3000
143
+
144
+ # Or via Vite
145
+ VITE_API_URL=http://localhost:3000
146
+ ```
147
+
148
+ ### Custom Configuration
149
+
150
+ ```typescript
151
+ import { createBetterAuthClient } from '~/lib/auth-client';
152
+
153
+ // Create a custom client
154
+ const customClient = createBetterAuthClient({
155
+ baseURL: 'https://api.example.com',
156
+ basePath: '/auth', // Default: '/iam'
157
+ twoFactorRedirectPath: '/login/2fa', // Default: '/auth/2fa'
158
+ enableAdmin: false,
159
+ enableTwoFactor: true,
160
+ enablePasskey: true,
161
+ });
162
+ ```
163
+
164
+ ## Security
165
+
166
+ ### Password Hashing
167
+
168
+ Passwords are hashed with SHA256 on the client-side before transmission:
169
+
170
+ ```typescript
171
+ // This happens automatically in auth-client.ts
172
+ const hashedPassword = await sha256(plainPassword);
173
+ // Result: 64-character hex string
174
+ ```
175
+
176
+ **Why client-side hashing?**
177
+ 1. Prevents plain text passwords in network logs
178
+ 2. Works with nest-server's `normalizePasswordForIam()` which detects SHA256 hashes
179
+ 3. Server re-hashes with bcrypt for storage
180
+
181
+ ### Cookie-Based Sessions
182
+
183
+ Sessions are stored in cookies for SSR compatibility:
184
+
185
+ | Cookie | Purpose |
186
+ |-----------------------------|----------------------------|
187
+ | `auth-state` | User data (SSR-compatible) |
188
+ | `token` | Session token |
189
+ | `better-auth.session_token` | Better Auth native cookie |
190
+
191
+ ### Cross-Origin Requests
192
+
193
+ The client is configured with `credentials: 'include'` for cross-origin cookie handling:
194
+
195
+ ```typescript
196
+ // In auth-client.ts
197
+ fetchOptions: {
198
+ credentials: 'include',
199
+ }
200
+ ```
201
+
202
+ **Backend CORS Configuration:**
203
+ ```typescript
204
+ // In nest-server config
205
+ cors: {
206
+ origin: 'http://localhost:3001', // Not '*'
207
+ credentials: true,
208
+ }
209
+ ```
210
+
211
+ ## Better Auth Endpoints
212
+
213
+ The following endpoints are provided by the nest-server backend:
214
+
215
+ ### Authentication
216
+
217
+ | Endpoint | Method | Description |
218
+ |----------------------|--------|-----------------------------|
219
+ | `/iam/sign-in/email` | POST | Email/password sign in |
220
+ | `/iam/sign-up/email` | POST | Email/password registration |
221
+ | `/iam/sign-out` | POST | Sign out |
222
+ | `/iam/session` | GET | Get current session |
223
+
224
+ ### Passkey (WebAuthn)
225
+
226
+ | Endpoint | Method | Description |
227
+ |----------------------------------------------|--------|--------------------------|
228
+ | `/iam/passkey/generate-register-options` | GET | Get registration options |
229
+ | `/iam/passkey/verify-registration` | POST | Verify registration |
230
+ | `/iam/passkey/generate-authenticate-options` | GET | Get auth options |
231
+ | `/iam/passkey/verify-authentication` | POST | Verify authentication |
232
+ | `/iam/passkey/list-user-passkeys` | GET | List user's passkeys |
233
+ | `/iam/passkey/delete-passkey` | POST | Delete a passkey |
234
+
235
+ ### Two-Factor Authentication
236
+
237
+ | Endpoint | Method | Description |
238
+ |--------------------------------------|--------|--------------------|
239
+ | `/iam/two-factor/enable` | POST | Enable 2FA |
240
+ | `/iam/two-factor/disable` | POST | Disable 2FA |
241
+ | `/iam/two-factor/verify-totp` | POST | Verify TOTP code |
242
+ | `/iam/two-factor/verify-backup-code` | POST | Verify backup code |
243
+
244
+ ## Troubleshooting
245
+
246
+ ### "Passkey not found" Error
247
+
248
+ 1. Ensure the user has registered a passkey first
249
+ 2. Check that cookies are being sent (`credentials: 'include'`)
250
+ 3. Verify CORS is configured correctly on the backend
251
+
252
+ ### 2FA Redirect Not Working
253
+
254
+ Ensure the 2FA redirect is handled in the login page:
255
+
256
+ ```typescript
257
+ // Check for 2FA redirect in login response
258
+ if (result.data?.twoFactorRedirect) {
259
+ await navigateTo('/auth/2fa');
260
+ return;
261
+ }
262
+ ```
263
+
264
+ ### Session Not Persisting After Passkey Login
265
+
266
+ The passkey response only contains the session, not the user. Call `validateSession()`:
267
+
268
+ ```typescript
269
+ if (result.data?.session) {
270
+ await validateSession(); // Fetches user data
271
+ }
272
+ ```
273
+
274
+ ### Form Not Submitting (Nuxt UI)
275
+
276
+ Ensure UForm has the `:state` binding:
277
+
278
+ ```vue
279
+ <UForm :schema="schema" :state="formState" @submit="onSubmit">
280
+ <UInput v-model="formState.field" />
281
+ </UForm>
282
+ ```
283
+
284
+ ## References
285
+
286
+ - [Better Auth Documentation](https://www.better-auth.com/docs)
287
+ - [Better Auth Passkey Plugin](https://www.better-auth.com/docs/plugins/passkey)
288
+ - [Better Auth Two-Factor Plugin](https://www.better-auth.com/docs/plugins/two-factor)
289
+ - [nest-server Better Auth Integration](https://github.com/lenneTech/nest-server)
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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.0](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.3...v1.1.0) (2026-01-20)
6
+
7
+
8
+ ### Features
9
+
10
+ * add complete Better-Auth integration with Passkey support and comprehensive documentation ([e0d470c](https://github.com/lenneTech/nuxt-base-starter/commit/e0d470c8229c37bed2948d929676620f344f4878))
11
+
12
+ ## [1.2.0](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.3...v1.2.0) (2026-01-20)
13
+
14
+
15
+ ### Features
16
+
17
+ * add complete Better-Auth integration with Passkey support and comprehensive documentation ([70fbec1](https://github.com/lenneTech/nuxt-base-starter/commit/70fbec14e38673c5185195fe05f0cd82bf72a800))
18
+
19
+ ## [1.1.0](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.3...v1.1.0) (2026-01-20)
20
+
5
21
  ### [1.0.3](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.2...v1.0.3) (2026-01-12)
6
22
 
7
23
  ### [1.0.2](https://github.com/lenneTech/nuxt-base-starter/compare/v1.0.1...v1.0.2) (2026-01-12)
package/README.md CHANGED
@@ -16,29 +16,37 @@ The development server starts at **http://localhost:3001**
16
16
 
17
17
  ### Core Framework
18
18
 
19
- | Technology | Version | Description |
20
- |------------|---------|-------------|
21
- | Nuxt | 4.x | Vue 3 meta-framework with SSR support |
22
- | TypeScript | 5.9.x | Strict type checking enabled |
23
- | Tailwind CSS | 4.x | Utility-first CSS with Vite plugin |
24
- | NuxtUI | 4.x | Component library with dark mode |
19
+ | Technology | Version | Description |
20
+ |--------------|---------|---------------------------------------|
21
+ | Nuxt | 4.x | Vue 3 meta-framework with SSR support |
22
+ | TypeScript | 5.9.x | Strict type checking enabled |
23
+ | Tailwind CSS | 4.x | Utility-first CSS with Vite plugin |
24
+ | NuxtUI | 4.x | Component library with dark mode |
25
25
 
26
26
  ### Authentication (Better Auth)
27
27
 
28
- - Email/password authentication with client-side password hashing
29
- - Two-factor authentication (2FA/TOTP)
30
- - Passkey/WebAuthn support
31
- - Password reset flow
32
- - Pre-built auth pages: login, register, forgot-password, reset-password, 2fa
28
+ Complete authentication system using [Better Auth](https://www.better-auth.com/):
29
+
30
+ | Feature | Description |
31
+ |--------------------|-------------------------------------------------------|
32
+ | Email/Password | Standard auth with client-side SHA256 hashing |
33
+ | Two-Factor (2FA) | TOTP-based 2FA with backup codes |
34
+ | Passkey/WebAuthn | Passwordless authentication (Touch ID, Face ID, etc.) |
35
+ | Password Reset | Email-based password reset flow |
36
+ | Session Management | SSR-compatible cookie-based sessions |
37
+
38
+ Pre-built auth pages: login, register, forgot-password, reset-password, 2fa
39
+
40
+ 📖 **See [AUTH.md](./AUTH.md) for detailed documentation**
33
41
 
34
42
  ### State & Data
35
43
 
36
- | Package | Purpose |
37
- |---------|---------|
38
- | Pinia | State management |
39
- | VueUse | Vue composition utilities |
40
- | @hey-api/client-fetch | Type-safe API client |
41
- | Valibot | Schema validation for forms |
44
+ | Package | Purpose |
45
+ |-----------------------|-----------------------------|
46
+ | Pinia | State management |
47
+ | VueUse | Vue composition utilities |
48
+ | @hey-api/client-fetch | Type-safe API client |
49
+ | Valibot | Schema validation for forms |
42
50
 
43
51
  ### SEO & Analytics
44
52
 
@@ -48,13 +56,13 @@ The development server starts at **http://localhost:3001**
48
56
 
49
57
  ### Developer Experience
50
58
 
51
- | Tool | Purpose |
52
- |------|---------|
53
- | OxLint | Fast linting |
54
- | OxFmt | Code formatting |
55
- | Playwright | E2E testing |
59
+ | Tool | Purpose |
60
+ |--------------------|------------------------------------|
61
+ | OxLint | Fast linting |
62
+ | OxFmt | Code formatting |
63
+ | Playwright | E2E testing |
56
64
  | @lenne.tech/bug.lt | Bug reporting to Linear (dev only) |
57
- | dayjs-nuxt | Date/time handling |
65
+ | dayjs-nuxt | Date/time handling |
58
66
 
59
67
  ### File Upload
60
68
 
@@ -95,17 +103,17 @@ my-project/
95
103
 
96
104
  ## Available Scripts
97
105
 
98
- | Script | Description |
99
- |--------|-------------|
100
- | `npm run dev` | Start development server |
101
- | `npm run build` | Build for production |
102
- | `npm run preview` | Preview production build |
106
+ | Script | Description |
107
+ |--------------------------|----------------------------------------|
108
+ | `npm run dev` | Start development server |
109
+ | `npm run build` | Build for production |
110
+ | `npm run preview` | Preview production build |
103
111
  | `npm run generate-types` | Generate TypeScript types from OpenAPI |
104
- | `npm run test` | Run Playwright E2E tests |
105
- | `npm run lint` | Run OxLint |
106
- | `npm run format` | Run OxFmt |
107
- | `npm run check` | Run lint + format check |
108
- | `npm run fix` | Auto-fix lint + format issues |
112
+ | `npm run test` | Run Playwright E2E tests |
113
+ | `npm run lint` | Run OxLint |
114
+ | `npm run format` | Run OxFmt |
115
+ | `npm run check` | Run lint + format check |
116
+ | `npm run fix` | Auto-fix lint + format issues |
109
117
 
110
118
  ## Environment Variables
111
119
 
@@ -1,25 +1,192 @@
1
1
  import { authClient } from '~/lib/auth-client';
2
2
 
3
+ /**
4
+ * User type for Better Auth session
5
+ */
6
+ interface BetterAuthUser {
7
+ email: string;
8
+ emailVerified?: boolean;
9
+ id: string;
10
+ name?: string;
11
+ role?: string;
12
+ twoFactorEnabled?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Stored auth state (persisted in cookie for SSR compatibility)
17
+ */
18
+ interface StoredAuthState {
19
+ user: BetterAuthUser | null;
20
+ }
21
+
22
+ /**
23
+ * Better Auth composable with client-side state management
24
+ *
25
+ * This composable manages auth state using:
26
+ * 1. Client-side state stored in a cookie (for SSR compatibility)
27
+ * 2. Better Auth's session endpoint as a validation check
28
+ *
29
+ * The state is populated after login and cleared on logout.
30
+ */
3
31
  export function useBetterAuth() {
4
- const session = authClient.useSession();
32
+ // Use useCookie for SSR-compatible persistent state
33
+ const authState = useCookie<StoredAuthState>('auth-state', {
34
+ default: () => ({ user: null }),
35
+ maxAge: 60 * 60 * 24 * 7, // 7 days
36
+ sameSite: 'lax',
37
+ });
38
+
39
+ // Loading state
40
+ const isLoading = ref<boolean>(false);
5
41
 
6
- const user = computed<null | User>(() => (session.value.data?.user as User) ?? null);
42
+ // Computed properties based on stored state
43
+ const user = computed<BetterAuthUser | null>(() => authState.value?.user ?? null);
7
44
  const isAuthenticated = computed<boolean>(() => !!user.value);
8
45
  const isAdmin = computed<boolean>(() => user.value?.role === 'admin');
9
46
  const is2FAEnabled = computed<boolean>(() => user.value?.twoFactorEnabled ?? false);
10
- const isLoading = computed<boolean>(() => session.value.isPending);
47
+
48
+ /**
49
+ * Set user data after successful login/signup
50
+ */
51
+ function setUser(userData: BetterAuthUser | null): void {
52
+ authState.value = { user: userData };
53
+ }
54
+
55
+ /**
56
+ * Clear user data on logout
57
+ */
58
+ function clearUser(): void {
59
+ authState.value = { user: null };
60
+ }
61
+
62
+ /**
63
+ * Validate session with backend (called on app init)
64
+ * If session is invalid, clear the stored state
65
+ */
66
+ async function validateSession(): Promise<boolean> {
67
+ try {
68
+ // Try to get session from Better Auth
69
+ const session = authClient.useSession();
70
+
71
+ // Wait for session to load
72
+ if (session.value.isPending) {
73
+ await new Promise((resolve) => {
74
+ const unwatch = watch(
75
+ () => session.value.isPending,
76
+ (isPending) => {
77
+ if (!isPending) {
78
+ unwatch();
79
+ resolve(true);
80
+ }
81
+ },
82
+ { immediate: true },
83
+ );
84
+ });
85
+ }
86
+
87
+ // If session has user data, update our state
88
+ if (session.value.data?.user) {
89
+ setUser(session.value.data.user as BetterAuthUser);
90
+ return true;
91
+ }
92
+
93
+ // Session not found - check if we have a stored token cookie
94
+ // If we have auth-state but no session, it might be a mismatch
95
+ // For now, trust the stored state if token cookie exists
96
+ const tokenCookie = useCookie('token');
97
+ if (tokenCookie.value && authState.value?.user) {
98
+ // We have both token and stored user - trust it
99
+ return true;
100
+ }
101
+
102
+ // No valid session found - clear state
103
+ if (authState.value?.user) {
104
+ clearUser();
105
+ }
106
+ return false;
107
+ } catch (error) {
108
+ console.debug('Session validation failed:', error);
109
+ return !!authState.value?.user;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Sign in with email and password
115
+ */
116
+ const signIn = {
117
+ ...authClient.signIn,
118
+ email: async (params: { email: string; password: string; rememberMe?: boolean }, options?: any) => {
119
+ isLoading.value = true;
120
+ try {
121
+ const result = await authClient.signIn.email(params, options);
122
+
123
+ // Check for successful response with user data
124
+ if (result && 'user' in result && result.user) {
125
+ setUser(result.user as BetterAuthUser);
126
+ } else if (result && 'data' in result && result.data?.user) {
127
+ setUser(result.data.user as BetterAuthUser);
128
+ }
129
+
130
+ return result;
131
+ } finally {
132
+ isLoading.value = false;
133
+ }
134
+ },
135
+ };
136
+
137
+ /**
138
+ * Sign up with email and password
139
+ */
140
+ const signUp = {
141
+ ...authClient.signUp,
142
+ email: async (params: { email: string; name: string; password: string }, options?: any) => {
143
+ isLoading.value = true;
144
+ try {
145
+ const result = await authClient.signUp.email(params, options);
146
+
147
+ // Check for successful response with user data
148
+ if (result && 'user' in result && result.user) {
149
+ setUser(result.user as BetterAuthUser);
150
+ } else if (result && 'data' in result && result.data?.user) {
151
+ setUser(result.data.user as BetterAuthUser);
152
+ }
153
+
154
+ return result;
155
+ } finally {
156
+ isLoading.value = false;
157
+ }
158
+ },
159
+ };
160
+
161
+ /**
162
+ * Sign out
163
+ */
164
+ const signOut = async (options?: any) => {
165
+ isLoading.value = true;
166
+ try {
167
+ const result = await authClient.signOut(options);
168
+ // Clear user data on logout
169
+ clearUser();
170
+ return result;
171
+ } finally {
172
+ isLoading.value = false;
173
+ }
174
+ };
11
175
 
12
176
  return {
177
+ changePassword: authClient.changePassword,
178
+ clearUser,
13
179
  is2FAEnabled,
14
180
  isAdmin,
15
181
  isAuthenticated,
16
- isLoading,
182
+ isLoading: computed(() => isLoading.value),
17
183
  passkey: authClient.passkey,
18
- session,
19
- signIn: authClient.signIn,
20
- signOut: authClient.signOut,
21
- signUp: authClient.signUp,
184
+ setUser,
185
+ signIn,
186
+ signOut,
187
+ signUp,
22
188
  twoFactor: authClient.twoFactor,
23
189
  user,
190
+ validateSession,
24
191
  };
25
192
  }
@@ -34,102 +34,190 @@ export interface AuthResponse {
34
34
  };
35
35
  }
36
36
 
37
- // =============================================================================
38
- // Base Client Configuration
39
- // =============================================================================
40
-
41
- const baseClient = createAuthClient({
42
- basePath: '/iam', // IMPORTANT: Must match nest-server betterAuth.basePath, default: '/iam'
43
- baseURL: import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000',
44
- plugins: [
45
- adminClient(),
46
- twoFactorClient({
47
- onTwoFactorRedirect() {
48
- navigateTo('/auth/2fa');
49
- },
50
- }),
51
- passkeyClient(),
52
- ],
53
- });
37
+ /**
38
+ * Configuration options for the auth client factory
39
+ * All options have sensible defaults for nest-server compatibility
40
+ */
41
+ export interface AuthClientConfig {
42
+ /** API base URL (default: from env or http://localhost:3000) */
43
+ baseURL?: string;
44
+ /** Auth API base path (default: '/iam' - must match nest-server betterAuth.basePath) */
45
+ basePath?: string;
46
+ /** 2FA redirect path (default: '/auth/2fa') */
47
+ twoFactorRedirectPath?: string;
48
+ /** Enable admin plugin (default: true) */
49
+ enableAdmin?: boolean;
50
+ /** Enable 2FA plugin (default: true) */
51
+ enableTwoFactor?: boolean;
52
+ /** Enable passkey plugin (default: true) */
53
+ enablePasskey?: boolean;
54
+ }
54
55
 
55
56
  // =============================================================================
56
- // Auth Client with Password Hashing
57
+ // Auth Client Factory
57
58
  // =============================================================================
58
59
 
59
60
  /**
60
- * Extended auth client that hashes passwords before transmission.
61
+ * Creates a configured Better-Auth client with password hashing
62
+ *
63
+ * This factory function allows creating auth clients with custom configuration,
64
+ * making it reusable across different projects.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * // Default configuration (works with nest-server defaults)
69
+ * const authClient = createBetterAuthClient();
70
+ *
71
+ * // Custom configuration
72
+ * const authClient = createBetterAuthClient({
73
+ * baseURL: 'https://api.example.com',
74
+ * basePath: '/auth',
75
+ * twoFactorRedirectPath: '/login/2fa',
76
+ * });
77
+ * ```
61
78
  *
62
79
  * SECURITY: Passwords are hashed with SHA256 client-side to prevent
63
80
  * plain text password transmission over the network.
64
- *
65
- * The server's normalizePasswordForIam() detects SHA256 hashes (64 hex chars)
66
- * and processes them correctly.
67
81
  */
68
- export const authClient = {
69
- // Spread all base client properties and methods
70
- ...baseClient,
71
-
72
- /**
73
- * Change password for an authenticated user (both passwords are hashed)
74
- */
75
- changePassword: async (params: { currentPassword: string; newPassword: string }, options?: any) => {
76
- const [hashedCurrent, hashedNew] = await Promise.all([sha256(params.currentPassword), sha256(params.newPassword)]);
77
- return baseClient.changePassword?.({ currentPassword: hashedCurrent, newPassword: hashedNew }, options);
78
- },
79
-
80
- /**
81
- * Reset password with token (new password is hashed before sending)
82
- */
83
- resetPassword: async (params: { newPassword: string; token: string }, options?: any) => {
84
- const hashedPassword = await sha256(params.newPassword);
85
- return baseClient.resetPassword?.({ newPassword: hashedPassword, token: params.token }, options);
86
- },
87
-
88
- // Override signIn to hash password
89
- signIn: {
90
- ...baseClient.signIn,
91
- /**
92
- * Sign in with email and password (password is hashed before sending)
93
- */
94
- email: async (params: { email: string; password: string; rememberMe?: boolean }, options?: any) => {
95
- const hashedPassword = await sha256(params.password);
96
- return baseClient.signIn.email({ ...params, password: hashedPassword }, options);
82
+ export function createBetterAuthClient(config: AuthClientConfig = {}) {
83
+ const {
84
+ baseURL = import.meta.env?.VITE_API_URL || process.env.API_URL || 'http://localhost:3000',
85
+ basePath = '/iam',
86
+ twoFactorRedirectPath = '/auth/2fa',
87
+ enableAdmin = true,
88
+ enableTwoFactor = true,
89
+ enablePasskey = true,
90
+ } = config;
91
+
92
+ // Build plugins array based on configuration
93
+ const plugins: any[] = [];
94
+
95
+ if (enableAdmin) {
96
+ plugins.push(adminClient());
97
+ }
98
+
99
+ if (enableTwoFactor) {
100
+ plugins.push(
101
+ twoFactorClient({
102
+ onTwoFactorRedirect() {
103
+ navigateTo(twoFactorRedirectPath);
104
+ },
105
+ }),
106
+ );
107
+ }
108
+
109
+ if (enablePasskey) {
110
+ plugins.push(passkeyClient());
111
+ }
112
+
113
+ // Create base client with configuration
114
+ const baseClient = createAuthClient({
115
+ basePath,
116
+ baseURL,
117
+ fetchOptions: {
118
+ credentials: 'include', // Required for cross-origin cookie handling
97
119
  },
98
- },
120
+ plugins,
121
+ });
99
122
 
100
- // Explicitly pass through signOut (not captured by spread operator)
101
- signOut: baseClient.signOut,
123
+ // Return extended client with password hashing
124
+ return {
125
+ // Spread all base client properties and methods
126
+ ...baseClient,
127
+
128
+ // Explicitly pass through methods not captured by spread operator
129
+ useSession: baseClient.useSession,
130
+ passkey: (baseClient as any).passkey,
131
+ admin: (baseClient as any).admin,
132
+ $Infer: baseClient.$Infer,
133
+ $fetch: baseClient.$fetch,
134
+ $store: baseClient.$store,
102
135
 
103
- // Override signUp to hash password
104
- signUp: {
105
- ...baseClient.signUp,
106
136
  /**
107
- * Sign up with email and password (password is hashed before sending)
137
+ * Change password for an authenticated user (both passwords are hashed)
108
138
  */
109
- email: async (params: { email: string; name: string; password: string }, options?: any) => {
110
- const hashedPassword = await sha256(params.password);
111
- return baseClient.signUp.email({ ...params, password: hashedPassword }, options);
139
+ changePassword: async (params: { currentPassword: string; newPassword: string }, options?: any) => {
140
+ const [hashedCurrent, hashedNew] = await Promise.all([sha256(params.currentPassword), sha256(params.newPassword)]);
141
+ return baseClient.changePassword?.({ currentPassword: hashedCurrent, newPassword: hashedNew }, options);
112
142
  },
113
- },
114
143
 
115
- // Override twoFactor to hash passwords
116
- twoFactor: {
117
- ...baseClient.twoFactor,
118
144
  /**
119
- * Disable 2FA (password is hashed before sending)
145
+ * Reset password with token (new password is hashed before sending)
120
146
  */
121
- disable: async (params: { password: string }, options?: any) => {
122
- const hashedPassword = await sha256(params.password);
123
- return baseClient.twoFactor.disable({ password: hashedPassword }, options);
147
+ resetPassword: async (params: { newPassword: string; token: string }, options?: any) => {
148
+ const hashedPassword = await sha256(params.newPassword);
149
+ return baseClient.resetPassword?.({ newPassword: hashedPassword, token: params.token }, options);
124
150
  },
125
- /**
126
- * Enable 2FA (password is hashed before sending)
127
- */
128
- enable: async (params: { password: string }, options?: any) => {
129
- const hashedPassword = await sha256(params.password);
130
- return baseClient.twoFactor.enable({ password: hashedPassword }, options);
151
+
152
+ // Override signIn to hash password (keep passkey method from plugin)
153
+ signIn: {
154
+ ...baseClient.signIn,
155
+ /**
156
+ * Sign in with email and password (password is hashed before sending)
157
+ */
158
+ email: async (params: { email: string; password: string; rememberMe?: boolean }, options?: any) => {
159
+ const hashedPassword = await sha256(params.password);
160
+ return baseClient.signIn.email({ ...params, password: hashedPassword }, options);
161
+ },
162
+ /**
163
+ * Sign in with passkey (pass through to base client - provided by passkeyClient plugin)
164
+ * @see https://www.better-auth.com/docs/plugins/passkey
165
+ */
166
+ passkey: (baseClient.signIn as any).passkey,
167
+ },
168
+
169
+ // Explicitly pass through signOut (not captured by spread operator)
170
+ signOut: baseClient.signOut,
171
+
172
+ // Override signUp to hash password
173
+ signUp: {
174
+ ...baseClient.signUp,
175
+ /**
176
+ * Sign up with email and password (password is hashed before sending)
177
+ */
178
+ email: async (params: { email: string; name: string; password: string }, options?: any) => {
179
+ const hashedPassword = await sha256(params.password);
180
+ return baseClient.signUp.email({ ...params, password: hashedPassword }, options);
181
+ },
131
182
  },
132
- },
133
- };
134
183
 
135
- export type AuthClient = typeof authClient;
184
+ // Override twoFactor to hash passwords (provided by twoFactorClient plugin)
185
+ twoFactor: {
186
+ ...(baseClient as any).twoFactor,
187
+ /**
188
+ * Disable 2FA (password is hashed before sending)
189
+ */
190
+ disable: async (params: { password: string }, options?: any) => {
191
+ const hashedPassword = await sha256(params.password);
192
+ return (baseClient as any).twoFactor.disable({ password: hashedPassword }, options);
193
+ },
194
+ /**
195
+ * Enable 2FA (password is hashed before sending)
196
+ */
197
+ enable: async (params: { password: string }, options?: any) => {
198
+ const hashedPassword = await sha256(params.password);
199
+ return (baseClient as any).twoFactor.enable({ password: hashedPassword }, options);
200
+ },
201
+ /**
202
+ * Verify TOTP code (pass through to base client)
203
+ */
204
+ verifyTotp: (baseClient as any).twoFactor.verifyTotp,
205
+ /**
206
+ * Verify backup code (pass through to base client)
207
+ */
208
+ verifyBackupCode: (baseClient as any).twoFactor.verifyBackupCode,
209
+ },
210
+ };
211
+ }
212
+
213
+ // =============================================================================
214
+ // Default Auth Client Instance
215
+ // =============================================================================
216
+
217
+ /**
218
+ * Default auth client instance with standard nest-server configuration
219
+ * Use createBetterAuthClient() for custom configuration
220
+ */
221
+ export const authClient = createBetterAuthClient();
222
+
223
+ export type AuthClient = ReturnType<typeof createBetterAuthClient>;
@@ -13,6 +13,7 @@ import { authClient } from '~/lib/auth-client';
13
13
  // Composables
14
14
  // ============================================================================
15
15
  const toast = useToast();
16
+ const { setUser, validateSession } = useBetterAuth();
16
17
 
17
18
  // ============================================================================
18
19
  // Page Meta
@@ -28,6 +29,9 @@ const loading = ref<boolean>(false);
28
29
  const useBackupCode = ref<boolean>(false);
29
30
  const trustDevice = ref<boolean>(false);
30
31
 
32
+ // Form state for UForm
33
+ const formState = reactive({ code: '' });
34
+
31
35
  const schema = v.object({
32
36
  code: v.pipe(v.string('Code ist erforderlich'), v.minLength(6, 'Code muss mindestens 6 Zeichen haben')),
33
37
  });
@@ -41,35 +45,46 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
41
45
  loading.value = true;
42
46
 
43
47
  try {
48
+ let result: any;
49
+
44
50
  if (useBackupCode.value) {
45
- const { error } = await authClient.twoFactor.verifyBackupCode({
51
+ result = await authClient.twoFactor.verifyBackupCode({
46
52
  code: payload.data.code,
47
53
  });
48
54
 
49
- if (error) {
55
+ if (result.error) {
50
56
  toast.add({
51
57
  color: 'error',
52
- description: error.message || 'Backup-Code ungültig',
58
+ description: result.error.message || 'Backup-Code ungültig',
53
59
  title: 'Fehler',
54
60
  });
55
61
  return;
56
62
  }
57
63
  } else {
58
- const { error } = await authClient.twoFactor.verifyTotp({
64
+ result = await authClient.twoFactor.verifyTotp({
59
65
  code: payload.data.code,
60
66
  trustDevice: trustDevice.value,
61
67
  });
62
68
 
63
- if (error) {
69
+ if (result.error) {
64
70
  toast.add({
65
71
  color: 'error',
66
- description: error.message || 'Code ungültig',
72
+ description: result.error.message || 'Code ungültig',
67
73
  title: 'Fehler',
68
74
  });
69
75
  return;
70
76
  }
71
77
  }
72
78
 
79
+ // Update auth state with user data from response
80
+ const userData = result?.data?.user || result?.user;
81
+ if (userData) {
82
+ setUser(userData);
83
+ } else {
84
+ // Fallback: validate session to get user data
85
+ await validateSession();
86
+ }
87
+
73
88
  await navigateTo('/app');
74
89
  } finally {
75
90
  loading.value = false;
@@ -92,10 +107,10 @@ function toggleBackupCode(): void {
92
107
  </p>
93
108
  </div>
94
109
 
95
- <UForm :schema="schema" class="flex flex-col gap-4" @submit="onSubmit">
110
+ <UForm :schema="schema" :state="formState" class="flex flex-col gap-4" @submit="onSubmit">
96
111
  <UFormField :label="useBackupCode ? 'Backup-Code' : 'Authentifizierungscode'" name="code">
97
112
  <UInput
98
- name="code"
113
+ v-model="formState.code"
99
114
  :placeholder="useBackupCode ? 'Backup-Code eingeben' : '000000'"
100
115
  size="lg"
101
116
  class="text-center font-mono text-lg tracking-widest"
@@ -13,6 +13,7 @@ import { authClient } from '~/lib/auth-client';
13
13
  // Composables
14
14
  // ============================================================================
15
15
  const toast = useToast();
16
+ const { signIn, setUser, isLoading, validateSession } = useBetterAuth();
16
17
 
17
18
  // ============================================================================
18
19
  // Page Meta
@@ -51,22 +52,53 @@ const schema = v.object({
51
52
 
52
53
  type Schema = InferOutput<typeof schema>;
53
54
 
55
+ /**
56
+ * Handle passkey authentication
57
+ * Uses official Better Auth signIn.passkey() method
58
+ * @see https://www.better-auth.com/docs/plugins/passkey
59
+ */
54
60
  async function onPasskeyLogin(): Promise<void> {
55
61
  passkeyLoading.value = true;
56
62
 
57
63
  try {
58
- const { error } = await authClient.signIn.passkey();
64
+ // Use official Better Auth client method
65
+ // This calls: GET /passkey/generate-authenticate-options → POST /passkey/verify-authentication
66
+ const result = await authClient.signIn.passkey();
59
67
 
60
- if (error) {
68
+ // Check for error in response
69
+ if (result.error) {
61
70
  toast.add({
62
71
  color: 'error',
63
- description: error.message || 'Passkey-Anmeldung fehlgeschlagen',
72
+ description: result.error.message || 'Passkey-Anmeldung fehlgeschlagen',
64
73
  title: 'Fehler',
65
74
  });
66
75
  return;
67
76
  }
68
77
 
78
+ // Update auth state with user data if available
79
+ if (result.data?.user) {
80
+ setUser(result.data.user as any);
81
+ } else if (result.data?.session) {
82
+ // Passkey auth returns session without user - fetch user via session validation
83
+ await validateSession();
84
+ }
85
+
69
86
  await navigateTo('/app');
87
+ } catch (err: unknown) {
88
+ // Handle WebAuthn-specific errors
89
+ if (err instanceof Error && err.name === 'NotAllowedError') {
90
+ toast.add({
91
+ color: 'error',
92
+ description: 'Passkey-Authentifizierung wurde abgebrochen',
93
+ title: 'Fehler',
94
+ });
95
+ return;
96
+ }
97
+ toast.add({
98
+ color: 'error',
99
+ description: err instanceof Error ? err.message : 'Passkey-Anmeldung fehlgeschlagen',
100
+ title: 'Fehler',
101
+ });
70
102
  } finally {
71
103
  passkeyLoading.value = false;
72
104
  }
@@ -79,21 +111,48 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
79
111
  loading.value = true;
80
112
 
81
113
  try {
82
- const { error } = await authClient.signIn.email({
114
+ const result = await signIn.email({
83
115
  email: payload.data.email,
84
116
  password: payload.data.password,
85
117
  });
86
118
 
87
- if (error) {
119
+ // Check for error in response
120
+ if ('error' in result && result.error) {
88
121
  toast.add({
89
122
  color: 'error',
90
- description: error.message || 'Anmeldung fehlgeschlagen',
123
+ description: (result.error as { message?: string }).message || 'Anmeldung fehlgeschlagen',
91
124
  title: 'Fehler',
92
125
  });
93
126
  return;
94
127
  }
95
128
 
96
- await navigateTo('/app');
129
+ // Check if 2FA is required
130
+ const resultData = 'data' in result ? result.data : result;
131
+ if (resultData && 'twoFactorRedirect' in resultData && resultData.twoFactorRedirect) {
132
+ // Redirect to 2FA page
133
+ await navigateTo('/auth/2fa');
134
+ return;
135
+ }
136
+
137
+ // Check if login was successful (user data in response)
138
+ const userData = 'user' in result ? result.user : ('data' in result ? result.data?.user : null);
139
+ if (userData) {
140
+ // Auth state is already stored by useBetterAuth
141
+ // Navigate to app
142
+ await navigateTo('/app');
143
+ } else {
144
+ toast.add({
145
+ color: 'error',
146
+ description: 'Anmeldung fehlgeschlagen - keine Benutzerdaten erhalten',
147
+ title: 'Fehler',
148
+ });
149
+ }
150
+ } catch (err) {
151
+ toast.add({
152
+ color: 'error',
153
+ description: 'Ein unerwarteter Fehler ist aufgetreten',
154
+ title: 'Fehler',
155
+ });
97
156
  } finally {
98
157
  loading.value = false;
99
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nuxt-base",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
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": {