create-nuxt-base 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +1 -1
- package/AUTH.md +16 -14
- package/CHANGELOG.md +13 -11
- package/README.md +5 -5
- package/nuxt-base-template/README.md +11 -11
- package/nuxt-base-template/app/composables/use-better-auth.ts +242 -60
- package/nuxt-base-template/app/lib/auth-client.ts +8 -11
- package/nuxt-base-template/app/lib/auth-state.ts +206 -0
- package/nuxt-base-template/app/middleware/admin.global.ts +27 -7
- package/nuxt-base-template/app/middleware/auth.global.ts +23 -6
- package/nuxt-base-template/app/middleware/guest.global.ts +22 -7
- package/nuxt-base-template/app/pages/app/index.vue +3 -17
- package/nuxt-base-template/app/pages/auth/2fa.vue +38 -6
- package/nuxt-base-template/app/pages/auth/login.vue +13 -20
- package/nuxt-base-template/app/pages/auth/register.vue +5 -8
- package/nuxt-base-template/app/plugins/auth-interceptor.client.ts +23 -15
- package/nuxt-base-template/server/api/iam/[...path].ts +12 -4
- package/package.json +2 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared authentication state for Cookie/JWT dual-mode authentication
|
|
3
|
+
*
|
|
4
|
+
* This module provides a reactive state that is shared between:
|
|
5
|
+
* - auth-client.ts (uses it for customFetch)
|
|
6
|
+
* - use-better-auth.ts (manages the state)
|
|
7
|
+
*
|
|
8
|
+
* Auth Mode Strategy:
|
|
9
|
+
* 1. Primary: Session cookies (more secure, HttpOnly)
|
|
10
|
+
* 2. Fallback: JWT tokens (when cookies are not available/working)
|
|
11
|
+
*
|
|
12
|
+
* The state is persisted in cookies for SSR compatibility.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type AuthMode = 'cookie' | 'jwt';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the current auth mode from cookie
|
|
19
|
+
*/
|
|
20
|
+
export function getAuthMode(): AuthMode {
|
|
21
|
+
if (import.meta.server) return 'cookie';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
25
|
+
if (cookie) {
|
|
26
|
+
const parts = cookie.split('=');
|
|
27
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
28
|
+
const state = JSON.parse(value);
|
|
29
|
+
return state?.authMode || 'cookie';
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore parse errors
|
|
33
|
+
}
|
|
34
|
+
return 'cookie';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the JWT token from cookie
|
|
39
|
+
*/
|
|
40
|
+
export function getJwtToken(): string | null {
|
|
41
|
+
if (import.meta.server) return null;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('jwt-token='));
|
|
45
|
+
if (cookie) {
|
|
46
|
+
const parts = cookie.split('=');
|
|
47
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
48
|
+
// Handle JSON-encoded string (useCookie stores as JSON)
|
|
49
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
50
|
+
return JSON.parse(value);
|
|
51
|
+
}
|
|
52
|
+
return value || null;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore parse errors
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set JWT token in cookie
|
|
62
|
+
*/
|
|
63
|
+
export function setJwtToken(token: string | null): void {
|
|
64
|
+
if (import.meta.server) return;
|
|
65
|
+
|
|
66
|
+
const maxAge = 60 * 60 * 24 * 7; // 7 days
|
|
67
|
+
if (token) {
|
|
68
|
+
document.cookie = `jwt-token=${encodeURIComponent(JSON.stringify(token))}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
69
|
+
} else {
|
|
70
|
+
document.cookie = `jwt-token=; path=/; max-age=0`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Update auth mode in the auth-state cookie
|
|
76
|
+
*/
|
|
77
|
+
export function setAuthMode(mode: AuthMode): void {
|
|
78
|
+
if (import.meta.server) return;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
82
|
+
|
|
83
|
+
let state = { user: null, authMode: mode };
|
|
84
|
+
if (cookie) {
|
|
85
|
+
const parts = cookie.split('=');
|
|
86
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
87
|
+
state = { ...JSON.parse(value), authMode: mode };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const maxAge = 60 * 60 * 24 * 7; // 7 days
|
|
91
|
+
document.cookie = `auth-state=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
92
|
+
} catch {
|
|
93
|
+
// Ignore errors
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the API base URL
|
|
99
|
+
*/
|
|
100
|
+
export function getApiBase(): string {
|
|
101
|
+
const isDev = import.meta.dev;
|
|
102
|
+
if (isDev) {
|
|
103
|
+
return '/api/iam';
|
|
104
|
+
}
|
|
105
|
+
// In production, try to get from runtime config or fall back to default
|
|
106
|
+
if (typeof window !== 'undefined' && (window as any).__NUXT__?.config?.public?.apiUrl) {
|
|
107
|
+
return `${(window as any).__NUXT__.config.public.apiUrl}/iam`;
|
|
108
|
+
}
|
|
109
|
+
return 'http://localhost:3000/iam';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Attempt to switch to JWT mode by fetching a token
|
|
114
|
+
*/
|
|
115
|
+
export async function attemptJwtSwitch(): Promise<boolean> {
|
|
116
|
+
try {
|
|
117
|
+
const apiBase = getApiBase();
|
|
118
|
+
const response = await fetch(`${apiBase}/token`, {
|
|
119
|
+
method: 'GET',
|
|
120
|
+
credentials: 'include',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
const data = await response.json();
|
|
125
|
+
if (data.token) {
|
|
126
|
+
setJwtToken(data.token);
|
|
127
|
+
setAuthMode('jwt');
|
|
128
|
+
console.debug('[Auth] Switched to JWT mode');
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if user is authenticated (has auth-state with user)
|
|
140
|
+
*/
|
|
141
|
+
export function isAuthenticated(): boolean {
|
|
142
|
+
if (import.meta.server) return false;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
146
|
+
if (cookie) {
|
|
147
|
+
const parts = cookie.split('=');
|
|
148
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
149
|
+
const state = JSON.parse(value);
|
|
150
|
+
return !!state?.user;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Ignore parse errors
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Custom fetch function that handles Cookie/JWT dual-mode authentication
|
|
160
|
+
*
|
|
161
|
+
* This function:
|
|
162
|
+
* 1. In cookie mode: Uses credentials: 'include'
|
|
163
|
+
* 2. In JWT mode: Adds Authorization header
|
|
164
|
+
* 3. On 401 in cookie mode: Attempts to switch to JWT and retries
|
|
165
|
+
*/
|
|
166
|
+
export async function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
167
|
+
const authMode = getAuthMode();
|
|
168
|
+
const jwtToken = getJwtToken();
|
|
169
|
+
|
|
170
|
+
const headers = new Headers(init?.headers);
|
|
171
|
+
|
|
172
|
+
// In JWT mode, add Authorization header
|
|
173
|
+
if (authMode === 'jwt' && jwtToken) {
|
|
174
|
+
headers.set('Authorization', `Bearer ${jwtToken}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Always include credentials for cookie-based session auth
|
|
178
|
+
// In JWT mode, cookies are sent but ignored by the server (Authorization header is used instead)
|
|
179
|
+
// This is more robust than conditionally omitting cookies
|
|
180
|
+
const response = await fetch(input, {
|
|
181
|
+
...init,
|
|
182
|
+
headers,
|
|
183
|
+
credentials: 'include',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// If we get 401 in cookie mode and user is authenticated, try JWT fallback
|
|
187
|
+
if (response.status === 401 && authMode === 'cookie' && isAuthenticated()) {
|
|
188
|
+
console.debug('[Auth] Cookie auth failed, attempting JWT fallback...');
|
|
189
|
+
const switched = await attemptJwtSwitch();
|
|
190
|
+
|
|
191
|
+
if (switched) {
|
|
192
|
+
// Retry the request with JWT
|
|
193
|
+
const newToken = getJwtToken();
|
|
194
|
+
if (newToken) {
|
|
195
|
+
headers.set('Authorization', `Bearer ${newToken}`);
|
|
196
|
+
return fetch(input, {
|
|
197
|
+
...init,
|
|
198
|
+
headers,
|
|
199
|
+
credentials: 'include',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return response;
|
|
206
|
+
}
|
|
@@ -4,20 +4,40 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let isAuthenticated = false;
|
|
8
|
+
let isAdmin = false;
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
if (
|
|
11
|
-
|
|
10
|
+
// On client, read directly from document.cookie for accurate state
|
|
11
|
+
if (import.meta.client) {
|
|
12
|
+
try {
|
|
13
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
14
|
+
if (cookie) {
|
|
15
|
+
const parts = cookie.split('=');
|
|
16
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
17
|
+
const state = JSON.parse(value);
|
|
18
|
+
isAuthenticated = !!state?.user;
|
|
19
|
+
isAdmin = state?.user?.role === 'admin';
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Ignore parse errors
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
// On server, use useCookie
|
|
26
|
+
const authStateCookie = useCookie<{ user: { role?: string } | null; authMode: string } | null>('auth-state');
|
|
27
|
+
isAuthenticated = !!authStateCookie.value?.user;
|
|
28
|
+
isAdmin = authStateCookie.value?.user?.role === 'admin';
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
// Redirect to login if not authenticated
|
|
15
|
-
if (!isAuthenticated
|
|
16
|
-
return navigateTo(
|
|
32
|
+
if (!isAuthenticated) {
|
|
33
|
+
return navigateTo({
|
|
34
|
+
path: '/auth/login',
|
|
35
|
+
query: { redirect: to.fullPath },
|
|
36
|
+
});
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
// Redirect to /app if authenticated but not admin
|
|
20
|
-
if (!isAdmin
|
|
40
|
+
if (!isAdmin) {
|
|
21
41
|
return navigateTo('/app');
|
|
22
42
|
}
|
|
23
43
|
});
|
|
@@ -4,15 +4,32 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let isAuthenticated = false;
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
if (
|
|
11
|
-
|
|
9
|
+
// On client, read directly from document.cookie for accurate state
|
|
10
|
+
if (import.meta.client) {
|
|
11
|
+
try {
|
|
12
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
13
|
+
if (cookie) {
|
|
14
|
+
const parts = cookie.split('=');
|
|
15
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
16
|
+
const state = JSON.parse(value);
|
|
17
|
+
isAuthenticated = !!state?.user;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore parse errors
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
// On server, use useCookie
|
|
24
|
+
const authStateCookie = useCookie<{ user: unknown; authMode: string } | null>('auth-state');
|
|
25
|
+
isAuthenticated = !!authStateCookie.value?.user;
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
// Redirect to login if not authenticated
|
|
15
|
-
if (!isAuthenticated
|
|
16
|
-
return navigateTo(
|
|
29
|
+
if (!isAuthenticated) {
|
|
30
|
+
return navigateTo({
|
|
31
|
+
path: '/auth/login',
|
|
32
|
+
query: { redirect: to.fullPath },
|
|
33
|
+
});
|
|
17
34
|
}
|
|
18
35
|
});
|
|
@@ -4,15 +4,30 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|
|
4
4
|
return;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let isAuthenticated = false;
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
if (
|
|
11
|
-
|
|
9
|
+
// On client, read directly from document.cookie for accurate state
|
|
10
|
+
if (import.meta.client) {
|
|
11
|
+
try {
|
|
12
|
+
const cookie = document.cookie.split('; ').find((row) => row.startsWith('auth-state='));
|
|
13
|
+
if (cookie) {
|
|
14
|
+
const parts = cookie.split('=');
|
|
15
|
+
const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : '';
|
|
16
|
+
const state = JSON.parse(value);
|
|
17
|
+
isAuthenticated = !!state?.user;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore parse errors
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
// On server, use useCookie
|
|
24
|
+
const authStateCookie = useCookie<{ user: unknown; authMode: string } | null>('auth-state');
|
|
25
|
+
isAuthenticated = !!authStateCookie.value?.user;
|
|
12
26
|
}
|
|
13
27
|
|
|
14
|
-
// Redirect to /app if already authenticated
|
|
15
|
-
if (isAuthenticated
|
|
16
|
-
|
|
28
|
+
// Redirect to /app (or redirect query) if already authenticated
|
|
29
|
+
if (isAuthenticated) {
|
|
30
|
+
const redirect = to.query.redirect as string;
|
|
31
|
+
return navigateTo(redirect || '/app');
|
|
17
32
|
}
|
|
18
33
|
});
|
|
@@ -29,9 +29,7 @@ async function handleSignOut(): Promise<void> {
|
|
|
29
29
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
|
30
30
|
<!-- Welcome Header -->
|
|
31
31
|
<div class="mb-8">
|
|
32
|
-
<h1 class="text-3xl font-bold">
|
|
33
|
-
Willkommen{{ user?.name ? `, ${user.name}` : '' }}!
|
|
34
|
-
</h1>
|
|
32
|
+
<h1 class="text-3xl font-bold">Willkommen{{ user?.name ? `, ${user.name}` : '' }}!</h1>
|
|
35
33
|
<p class="mt-2 text-muted">
|
|
36
34
|
{{ user?.email }}
|
|
37
35
|
</p>
|
|
@@ -41,12 +39,7 @@ async function handleSignOut(): Promise<void> {
|
|
|
41
39
|
<div class="mb-8">
|
|
42
40
|
<h2 class="mb-4 text-xl font-semibold">Schnellzugriff</h2>
|
|
43
41
|
<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
|
-
>
|
|
42
|
+
<UCard v-for="page in pages" :key="page.to" class="cursor-pointer transition-shadow hover:shadow-lg" @click="navigateTo(page.to)">
|
|
50
43
|
<div class="flex items-start gap-4">
|
|
51
44
|
<div class="rounded-lg bg-primary/10 p-3">
|
|
52
45
|
<UIcon :name="page.icon" class="size-6 text-primary" />
|
|
@@ -87,14 +80,7 @@ async function handleSignOut(): Promise<void> {
|
|
|
87
80
|
</div>
|
|
88
81
|
|
|
89
82
|
<template #footer>
|
|
90
|
-
<UButton
|
|
91
|
-
color="error"
|
|
92
|
-
variant="outline"
|
|
93
|
-
icon="i-lucide-log-out"
|
|
94
|
-
@click="handleSignOut"
|
|
95
|
-
>
|
|
96
|
-
Abmelden
|
|
97
|
-
</UButton>
|
|
83
|
+
<UButton color="error" variant="outline" icon="i-lucide-log-out" @click="handleSignOut"> Abmelden </UButton>
|
|
98
84
|
</template>
|
|
99
85
|
</UCard>
|
|
100
86
|
</div>
|
|
@@ -13,7 +13,7 @@ import { authClient } from '~/lib/auth-client';
|
|
|
13
13
|
// Composables
|
|
14
14
|
// ============================================================================
|
|
15
15
|
const toast = useToast();
|
|
16
|
-
const { setUser,
|
|
16
|
+
const { fetchWithAuth, setUser, switchToJwtMode, jwtToken } = useBetterAuth();
|
|
17
17
|
|
|
18
18
|
// ============================================================================
|
|
19
19
|
// Page Meta
|
|
@@ -76,13 +76,45 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
//
|
|
79
|
+
// Extract token and user data from response (JWT mode: cookies: false)
|
|
80
|
+
const token = result?.token || result?.data?.token;
|
|
80
81
|
const userData = result?.data?.user || result?.user;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
if (token) {
|
|
84
|
+
// JWT mode: Token is in the response
|
|
85
|
+
jwtToken.value = token;
|
|
86
|
+
if (userData) {
|
|
87
|
+
setUser(userData, 'jwt');
|
|
88
|
+
}
|
|
89
|
+
console.debug('[Auth] JWT token received from 2FA response');
|
|
90
|
+
} else if (userData) {
|
|
91
|
+
// Cookie mode: No token in response, use cookies
|
|
92
|
+
setUser(userData, 'cookie');
|
|
93
|
+
// Try to get JWT token for fallback
|
|
94
|
+
switchToJwtMode().catch(() => {});
|
|
83
95
|
} else {
|
|
84
|
-
// Fallback:
|
|
85
|
-
|
|
96
|
+
// Fallback: fetch session data from API using authenticated fetch
|
|
97
|
+
try {
|
|
98
|
+
const isDev = import.meta.dev;
|
|
99
|
+
const runtimeConfig = useRuntimeConfig();
|
|
100
|
+
const apiBase = isDev ? '/api/iam' : `${runtimeConfig.public.apiUrl || 'http://localhost:3000'}/iam`;
|
|
101
|
+
const sessionResponse = await fetchWithAuth(`${apiBase}/get-session`);
|
|
102
|
+
if (sessionResponse.ok) {
|
|
103
|
+
const sessionData = await sessionResponse.json();
|
|
104
|
+
const sessionToken = sessionData?.token;
|
|
105
|
+
if (sessionToken) {
|
|
106
|
+
jwtToken.value = sessionToken;
|
|
107
|
+
if (sessionData?.user) {
|
|
108
|
+
setUser(sessionData.user, 'jwt');
|
|
109
|
+
}
|
|
110
|
+
} else if (sessionData?.user) {
|
|
111
|
+
setUser(sessionData.user, 'cookie');
|
|
112
|
+
switchToJwtMode().catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Ignore session fetch errors
|
|
117
|
+
}
|
|
86
118
|
}
|
|
87
119
|
|
|
88
120
|
await navigateTo('/app');
|
|
@@ -7,13 +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();
|
|
16
|
-
const { signIn, setUser, isLoading, validateSession } = useBetterAuth();
|
|
14
|
+
const { signIn, setUser, isLoading, validateSession, authenticateWithPasskey } = useBetterAuth();
|
|
17
15
|
|
|
18
16
|
// ============================================================================
|
|
19
17
|
// Page Meta
|
|
@@ -54,32 +52,30 @@ type Schema = InferOutput<typeof schema>;
|
|
|
54
52
|
|
|
55
53
|
/**
|
|
56
54
|
* Handle passkey authentication
|
|
57
|
-
* Uses
|
|
58
|
-
* @see https://www.better-auth.com/docs/plugins/passkey
|
|
55
|
+
* Uses authenticateWithPasskey from composable which supports JWT mode (challengeId)
|
|
59
56
|
*/
|
|
60
57
|
async function onPasskeyLogin(): Promise<void> {
|
|
61
58
|
passkeyLoading.value = true;
|
|
62
59
|
|
|
63
60
|
try {
|
|
64
|
-
// Use
|
|
65
|
-
|
|
66
|
-
const result = await authClient.signIn.passkey();
|
|
61
|
+
// Use composable method which handles challengeId for JWT mode
|
|
62
|
+
const result = await authenticateWithPasskey();
|
|
67
63
|
|
|
68
|
-
// Check for error in response
|
|
69
|
-
if (result.
|
|
64
|
+
// Check for error in response (authenticateWithPasskey returns { success, error?, user? })
|
|
65
|
+
if (!result.success) {
|
|
70
66
|
toast.add({
|
|
71
67
|
color: 'error',
|
|
72
|
-
description: result.error
|
|
68
|
+
description: result.error || 'Passkey-Anmeldung fehlgeschlagen',
|
|
73
69
|
title: 'Fehler',
|
|
74
70
|
});
|
|
75
71
|
return;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
// Update auth state with user data if available
|
|
79
|
-
if (result.
|
|
80
|
-
setUser(result.
|
|
81
|
-
} else
|
|
82
|
-
// Passkey auth
|
|
75
|
+
if (result.user) {
|
|
76
|
+
setUser(result.user as any);
|
|
77
|
+
} else {
|
|
78
|
+
// Passkey auth may return success without user - fetch user via session validation
|
|
83
79
|
await validateSession();
|
|
84
80
|
}
|
|
85
81
|
|
|
@@ -129,10 +125,7 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
129
125
|
// Check if 2FA is required
|
|
130
126
|
// Better-Auth native uses 'twoFactorRedirect', nest-server REST API uses 'requiresTwoFactor'
|
|
131
127
|
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
|
-
);
|
|
128
|
+
const requires2FA = resultData && (('twoFactorRedirect' in resultData && resultData.twoFactorRedirect) || ('requiresTwoFactor' in resultData && resultData.requiresTwoFactor));
|
|
136
129
|
if (requires2FA) {
|
|
137
130
|
// Redirect to 2FA page
|
|
138
131
|
await navigateTo('/auth/2fa');
|
|
@@ -140,7 +133,7 @@ async function onSubmit(payload: FormSubmitEvent<Schema>): Promise<void> {
|
|
|
140
133
|
}
|
|
141
134
|
|
|
142
135
|
// Check if login was successful (user data in response)
|
|
143
|
-
const userData = 'user' in result ? result.user :
|
|
136
|
+
const userData = 'user' in result ? result.user : 'data' in result ? result.data?.user : null;
|
|
144
137
|
if (userData) {
|
|
145
138
|
// Auth state is already stored by useBetterAuth
|
|
146
139
|
// Navigate to app
|
|
@@ -7,13 +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();
|
|
16
|
-
const { signUp, signIn } = useBetterAuth();
|
|
14
|
+
const { signUp, signIn, registerPasskey } = useBetterAuth();
|
|
17
15
|
|
|
18
16
|
// ============================================================================
|
|
19
17
|
// Page Meta
|
|
@@ -136,14 +134,13 @@ async function addPasskey(): Promise<void> {
|
|
|
136
134
|
passkeyLoading.value = true;
|
|
137
135
|
|
|
138
136
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
137
|
+
// Use registerPasskey from composable which properly handles challengeId
|
|
138
|
+
const result = await registerPasskey('Mein Gerät');
|
|
142
139
|
|
|
143
|
-
if (
|
|
140
|
+
if (!result.success) {
|
|
144
141
|
toast.add({
|
|
145
142
|
color: 'error',
|
|
146
|
-
description: error
|
|
143
|
+
description: result.error || 'Passkey konnte nicht hinzugefügt werden',
|
|
147
144
|
title: 'Fehler',
|
|
148
145
|
});
|
|
149
146
|
// Still navigate to app even if passkey failed
|
|
@@ -18,13 +18,7 @@ export default defineNuxtPlugin(() => {
|
|
|
18
18
|
|
|
19
19
|
// Paths that should not trigger auto-logout on 401
|
|
20
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
|
-
];
|
|
21
|
+
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/forgot-password', '/auth/reset-password', '/auth/2fa'];
|
|
28
22
|
|
|
29
23
|
/**
|
|
30
24
|
* Check if current route is a public auth route
|
|
@@ -35,7 +29,8 @@ export default defineNuxtPlugin(() => {
|
|
|
35
29
|
|
|
36
30
|
/**
|
|
37
31
|
* Check if URL is an auth-related endpoint that shouldn't trigger logout
|
|
38
|
-
* (e.g., login, register, password reset endpoints)
|
|
32
|
+
* (e.g., login, register, password reset, passkey endpoints)
|
|
33
|
+
* These endpoints use the authFetch wrapper which handles JWT fallback
|
|
39
34
|
*/
|
|
40
35
|
function isAuthEndpoint(url: string): boolean {
|
|
41
36
|
const authEndpoints = [
|
|
@@ -46,6 +41,16 @@ export default defineNuxtPlugin(() => {
|
|
|
46
41
|
'/reset-password',
|
|
47
42
|
'/verify-email',
|
|
48
43
|
'/session',
|
|
44
|
+
'/token',
|
|
45
|
+
// Passkey endpoints - handled by authFetch with JWT fallback
|
|
46
|
+
'/passkey/',
|
|
47
|
+
'/list-user-passkeys',
|
|
48
|
+
'/generate-register-options',
|
|
49
|
+
'/verify-registration',
|
|
50
|
+
'/generate-authenticate-options',
|
|
51
|
+
'/verify-authentication',
|
|
52
|
+
// Two-factor endpoints
|
|
53
|
+
'/two-factor/',
|
|
49
54
|
];
|
|
50
55
|
return authEndpoints.some((endpoint) => url.includes(endpoint));
|
|
51
56
|
}
|
|
@@ -81,14 +86,17 @@ export default defineNuxtPlugin(() => {
|
|
|
81
86
|
clearUser();
|
|
82
87
|
|
|
83
88
|
// Redirect to login page with return URL
|
|
84
|
-
await navigateTo(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
await navigateTo(
|
|
90
|
+
{
|
|
91
|
+
path: '/auth/login',
|
|
92
|
+
query: {
|
|
93
|
+
redirect: route.fullPath !== '/auth/login' ? route.fullPath : undefined,
|
|
94
|
+
},
|
|
88
95
|
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
{
|
|
97
|
+
replace: true,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
92
100
|
}
|
|
93
101
|
} finally {
|
|
94
102
|
// Reset flag after a short delay to allow navigation to complete
|
|
@@ -28,8 +28,9 @@ export default defineEventHandler(async (event) => {
|
|
|
28
28
|
body = await readBody(event);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// Forward cookies from the incoming request
|
|
31
|
+
// Forward cookies and authorization from the incoming request
|
|
32
32
|
const cookieHeader = getHeader(event, 'cookie');
|
|
33
|
+
const authHeader = getHeader(event, 'authorization');
|
|
33
34
|
// Use the actual origin from the request, fallback to localhost
|
|
34
35
|
const originHeader = getHeader(event, 'origin') || getHeader(event, 'referer')?.replace(/\/[^/]*$/, '') || 'http://localhost:3001';
|
|
35
36
|
|
|
@@ -39,8 +40,9 @@ export default defineEventHandler(async (event) => {
|
|
|
39
40
|
body: body ? JSON.stringify(body) : undefined,
|
|
40
41
|
headers: {
|
|
41
42
|
'Content-Type': 'application/json',
|
|
42
|
-
|
|
43
|
+
Origin: originHeader,
|
|
43
44
|
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
|
45
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
44
46
|
},
|
|
45
47
|
credentials: 'include',
|
|
46
48
|
// Don't throw on error status codes
|
|
@@ -50,10 +52,16 @@ export default defineEventHandler(async (event) => {
|
|
|
50
52
|
// Forward Set-Cookie headers from the API response
|
|
51
53
|
const setCookieHeaders = response.headers.getSetCookie?.() || [];
|
|
52
54
|
for (const cookie of setCookieHeaders) {
|
|
53
|
-
// Rewrite cookie to work on localhost
|
|
55
|
+
// Rewrite cookie to work on localhost:
|
|
56
|
+
// 1. Remove domain (cookie will be set for current origin)
|
|
57
|
+
// 2. Remove secure flag (not needed for localhost)
|
|
58
|
+
// 3. Rewrite path from /iam to /api/iam (or set to / for broader access)
|
|
54
59
|
const rewrittenCookie = cookie
|
|
55
60
|
.replace(/domain=[^;]+;?\s*/gi, '')
|
|
56
|
-
.replace(/secure;?\s*/gi, '')
|
|
61
|
+
.replace(/secure;?\s*/gi, '')
|
|
62
|
+
// Ensure path is set to / so cookies work for all routes
|
|
63
|
+
.replace(/path=\/iam[^;]*;?\s*/gi, 'path=/; ')
|
|
64
|
+
.replace(/path=[^;]+;?\s*/gi, 'path=/; ');
|
|
57
65
|
appendResponseHeader(event, 'Set-Cookie', rewrittenCookie);
|
|
58
66
|
}
|
|
59
67
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-nuxt-base",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Starter to generate a configured environment with VueJS, Nuxt, Tailwind, Eslint, Unit Tests, Playwright etc.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": "lenne.Tech GmbH",
|
|
6
7
|
"repository": {
|
|
7
8
|
"url": "https://github.com/lenneTech/nuxt-base-starter"
|
|
8
9
|
},
|
|
9
|
-
"author": "lenne.Tech GmbH",
|
|
10
10
|
"bin": {
|
|
11
11
|
"create-nuxt-base": "./index.js"
|
|
12
12
|
},
|