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.
- package/AUTH.md +289 -0
- package/CHANGELOG.md +24 -0
- package/README.md +41 -33
- package/nuxt-base-template/app/composables/use-better-auth.ts +393 -8
- package/nuxt-base-template/app/lib/auth-client.ts +175 -78
- package/nuxt-base-template/app/pages/app/index.vue +101 -0
- package/nuxt-base-template/app/pages/app/settings/security.vue +30 -13
- package/nuxt-base-template/app/pages/auth/2fa.vue +23 -8
- package/nuxt-base-template/app/pages/auth/login.vue +71 -7
- package/nuxt-base-template/app/pages/auth/register.vue +49 -77
- package/nuxt-base-template/app/utils/crypto.ts +31 -0
- package/nuxt-base-template/server/api/iam/[...path].ts +65 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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,53 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
79
111
|
loading.value = true;
|
|
80
112
|
|
|
81
113
|
try {
|
|
82
|
-
const
|
|
114
|
+
const result = await signIn.email({
|
|
83
115
|
email: payload.data.email,
|
|
84
116
|
password: payload.data.password,
|
|
85
117
|
});
|
|
86
118
|
|
|
87
|
-
|
|
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
|
-
|
|
129
|
+
// Check if 2FA is required
|
|
130
|
+
// Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
|
|
131
|
+
const resultData = 'data' in result ? result.data : result;
|
|
132
|
+
const requires2FA = resultData && (
|
|
133
|
+
('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) ||
|
|
134
|
+
('requiresTwoFactor' in resultData && resultData.requiresTwoFactor)
|
|
135
|
+
);
|
|
136
|
+
if (requires2FA) {
|
|
137
|
+
// Redirect to 2FA page
|
|
138
|
+
await navigateTo('/auth/2fa');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check if login was successful (user data in response)
|
|
143
|
+
const userData = 'user' in result ? result.user : ('data' in result ? result.data?.user : null);
|
|
144
|
+
if (userData) {
|
|
145
|
+
// Auth state is already stored by useBetterAuth
|
|
146
|
+
// Navigate to app
|
|
147
|
+
await navigateTo('/app');
|
|
148
|
+
} else {
|
|
149
|
+
toast.add({
|
|
150
|
+
color: 'error',
|
|
151
|
+
description: 'Anmeldung fehlgeschlagen - keine Benutzerdaten erhalten',
|
|
152
|
+
title: 'Fehler',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
toast.add({
|
|
157
|
+
color: 'error',
|
|
158
|
+
description: 'Ein unerwarteter Fehler ist aufgetreten',
|
|
159
|
+
title: 'Fehler',
|
|
160
|
+
});
|
|
97
161
|
} finally {
|
|
98
162
|
loading.value = false;
|
|
99
163
|
}
|
|
@@ -7,12 +7,11 @@ import type { InferOutput } from 'valibot';
|
|
|
7
7
|
|
|
8
8
|
import * as v from 'valibot';
|
|
9
9
|
|
|
10
|
-
import { authClient } from '~/lib/auth-client';
|
|
11
|
-
|
|
12
10
|
// ============================================================================
|
|
13
11
|
// Composables
|
|
14
12
|
// ============================================================================
|
|
15
13
|
const toast = useToast();
|
|
14
|
+
const { signUp, signIn } = useBetterAuth();
|
|
16
15
|
|
|
17
16
|
// ============================================================================
|
|
18
17
|
// Page Meta
|
|
@@ -25,8 +24,6 @@ definePageMeta({
|
|
|
25
24
|
// Variables
|
|
26
25
|
// ============================================================================
|
|
27
26
|
const loading = ref<boolean>(false);
|
|
28
|
-
const showPasskeyPrompt = ref<boolean>(false);
|
|
29
|
-
const passkeyLoading = ref<boolean>(false);
|
|
30
27
|
|
|
31
28
|
const fields: AuthFormField[] = [
|
|
32
29
|
{
|
|
@@ -74,111 +71,86 @@ const schema = v.pipe(
|
|
|
74
71
|
|
|
75
72
|
type Schema = InferOutput<typeof schema>;
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Functions
|
|
76
|
+
// ============================================================================
|
|
77
|
+
async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
78
|
+
loading.value = true;
|
|
79
79
|
|
|
80
80
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Step 1: Sign up
|
|
82
|
+
const signUpResult = await signUp.email({
|
|
83
|
+
email: payload.data.email,
|
|
84
|
+
name: payload.data.name,
|
|
85
|
+
password: payload.data.password,
|
|
83
86
|
});
|
|
84
87
|
|
|
85
|
-
|
|
88
|
+
const signUpError = 'error' in signUpResult ? signUpResult.error : null;
|
|
89
|
+
|
|
90
|
+
if (signUpError) {
|
|
86
91
|
toast.add({
|
|
87
92
|
color: 'error',
|
|
88
|
-
description:
|
|
93
|
+
description: signUpError.message || 'Registrierung fehlgeschlagen',
|
|
89
94
|
title: 'Fehler',
|
|
90
95
|
});
|
|
91
96
|
return;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
description: 'Passkey wurde erfolgreich hinzugefügt',
|
|
97
|
-
title: 'Erfolg',
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
await navigateTo('/app');
|
|
101
|
-
} finally {
|
|
102
|
-
passkeyLoading.value = false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ============================================================================
|
|
107
|
-
// Functions
|
|
108
|
-
// ============================================================================
|
|
109
|
-
async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
110
|
-
loading.value = true;
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const { error } = await authClient.signUp.email({
|
|
99
|
+
// Step 2: Sign in to create session (required for passkey registration)
|
|
100
|
+
const signInResult = await signIn.email({
|
|
114
101
|
email: payload.data.email,
|
|
115
|
-
name: payload.data.name,
|
|
116
102
|
password: payload.data.password,
|
|
117
103
|
});
|
|
118
104
|
|
|
119
|
-
|
|
105
|
+
const signInError = 'error' in signInResult ? signInResult.error : null;
|
|
106
|
+
|
|
107
|
+
if (signInError) {
|
|
108
|
+
// Sign-up was successful but sign-in failed - still show success and redirect
|
|
120
109
|
toast.add({
|
|
121
|
-
color: '
|
|
122
|
-
description:
|
|
123
|
-
title: '
|
|
110
|
+
color: 'success',
|
|
111
|
+
description: 'Dein Konto wurde erstellt. Bitte melde dich an.',
|
|
112
|
+
title: 'Willkommen!',
|
|
124
113
|
});
|
|
114
|
+
await navigateTo('/auth/login');
|
|
125
115
|
return;
|
|
126
116
|
}
|
|
127
117
|
|
|
128
118
|
toast.add({
|
|
129
119
|
color: 'success',
|
|
130
|
-
description: 'Dein Konto wurde erfolgreich erstellt',
|
|
120
|
+
description: 'Dein Konto wurde erfolgreich erstellt. Du kannst Passkeys später in den Sicherheitseinstellungen hinzufügen.',
|
|
131
121
|
title: 'Willkommen!',
|
|
132
122
|
});
|
|
133
123
|
|
|
134
|
-
|
|
124
|
+
// Navigate to app - passkeys can be added later in security settings
|
|
125
|
+
// Note: Immediate passkey registration after sign-up is currently not supported
|
|
126
|
+
// due to session handling differences between nest-server and Better Auth
|
|
127
|
+
await navigateTo('/app', { external: true });
|
|
135
128
|
} finally {
|
|
136
129
|
loading.value = false;
|
|
137
130
|
}
|
|
138
131
|
}
|
|
139
|
-
|
|
140
|
-
async function skipPasskey(): Promise<void> {
|
|
141
|
-
await navigateTo('/app');
|
|
142
|
-
}
|
|
143
132
|
</script>
|
|
144
133
|
|
|
145
134
|
<template>
|
|
146
135
|
<UPageCard class="w-md" variant="naked">
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
>
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
</UAuthForm>
|
|
167
|
-
</template>
|
|
168
|
-
|
|
169
|
-
<template v-else>
|
|
170
|
-
<div class="flex flex-col items-center gap-6">
|
|
171
|
-
<UIcon name="i-lucide-key" class="size-16 text-primary" />
|
|
172
|
-
<div class="text-center">
|
|
173
|
-
<h2 class="text-xl font-semibold">Passkey hinzufügen?</h2>
|
|
174
|
-
<p class="mt-2 text-sm text-muted">Mit einem Passkey kannst du dich schnell und sicher ohne Passwort anmelden.</p>
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
<div class="flex w-full flex-col gap-3">
|
|
178
|
-
<UButton block :loading="passkeyLoading" @click="addPasskey"> Passkey hinzufügen </UButton>
|
|
179
|
-
<UButton block variant="outline" color="neutral" @click="skipPasskey"> Später einrichten </UButton>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
</template>
|
|
136
|
+
<UAuthForm
|
|
137
|
+
:schema="schema"
|
|
138
|
+
title="Registrieren"
|
|
139
|
+
icon="i-lucide-user-plus"
|
|
140
|
+
:fields="fields"
|
|
141
|
+
:loading="loading"
|
|
142
|
+
:submit="{
|
|
143
|
+
label: 'Konto erstellen',
|
|
144
|
+
block: true,
|
|
145
|
+
}"
|
|
146
|
+
@submit="onSubmit"
|
|
147
|
+
>
|
|
148
|
+
<template #footer>
|
|
149
|
+
<p class="text-center text-sm text-muted">
|
|
150
|
+
Bereits ein Konto?
|
|
151
|
+
<ULink to="/auth/login" class="text-primary font-medium">Anmelden</ULink>
|
|
152
|
+
</p>
|
|
153
|
+
</template>
|
|
154
|
+
</UAuthForm>
|
|
183
155
|
</UPageCard>
|
|
184
156
|
</template>
|
|
@@ -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