create-stackr 0.2.0 → 0.3.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/README.md +10 -0
- package/dist/prompts/features.d.ts +1 -1
- package/dist/prompts/features.d.ts.map +1 -1
- package/dist/prompts/features.js +34 -25
- package/dist/prompts/features.js.map +1 -1
- package/dist/prompts/index.js +33 -6
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/preset.d.ts.map +1 -1
- package/dist/prompts/preset.js +69 -34
- package/dist/prompts/preset.js.map +1 -1
- package/dist/utils/template.js +1 -1
- package/dist/utils/template.js.map +1 -1
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +43 -1
- package/dist/utils/validation.js.map +1 -1
- package/package.json +1 -1
- package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +33 -1
- package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +6 -0
- package/templates/base/backend/domain/user/repository.drizzle.ts +28 -1
- package/templates/base/backend/domain/user/repository.prisma.ts +25 -0
- package/templates/base/backend/drizzle/{schema.drizzle.ts → schema.drizzle.ts.ejs} +25 -0
- package/templates/base/backend/lib/auth.drizzle.ts.ejs +27 -18
- package/templates/base/backend/lib/auth.prisma.ts.ejs +24 -18
- package/templates/base/backend/package.json.ejs +29 -23
- package/templates/base/backend/prisma/schema.prisma.ejs +20 -0
- package/templates/base/mobile/app/+not-found.tsx +1 -1
- package/templates/base/mobile/app/_layout.tsx.ejs +95 -10
- package/templates/base/mobile/package.json.ejs +21 -13
- package/templates/base/mobile/src/components/ui/Button.tsx +5 -3
- package/templates/base/mobile/src/components/ui/Card.tsx +1 -1
- package/templates/base/mobile/src/components/ui/Input.tsx +1 -1
- package/templates/base/mobile/src/components/ui/Skeleton.tsx +1 -1
- package/templates/base/mobile/src/components/ui/Toast.tsx +106 -0
- package/templates/base/mobile/src/components/ui/{IconSymbol.tsx → icon-symbol.tsx} +7 -0
- package/templates/base/mobile/src/components/ui/{LoadingSpinner.tsx → loading-spinner.tsx} +1 -1
- package/templates/base/mobile/src/components/ui/{OnboardingLayout.tsx → onboarding-layout.tsx} +2 -2
- package/templates/base/mobile/src/components/ui/{PaywallLayout.tsx → paywall-layout.tsx} +3 -3
- package/templates/base/mobile/src/constants/Theme.ts +3 -3
- package/templates/base/mobile/src/context/{ThemeContext.tsx → theme-context.tsx} +2 -2
- package/templates/base/mobile/src/lib/auth-client.ts.ejs +18 -0
- package/templates/base/mobile/src/services/{sdkInitializer.ts.ejs → sdk-initializer.ts.ejs} +4 -4
- package/templates/base/web/.prettierignore +6 -0
- package/templates/base/web/.prettierrc +8 -0
- package/templates/base/web/eslint.config.mjs +31 -7
- package/templates/base/web/next.config.ts +50 -1
- package/templates/base/web/package.json.ejs +14 -2
- package/templates/base/web/src/app/globals.css +1 -1
- package/templates/base/web/src/app/layout.tsx.ejs +2 -0
- package/templates/base/web/src/components/auth/protected-route.tsx.ejs +32 -5
- package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +1 -1
- package/templates/base/web/src/hooks/use-device-session.ts.ejs +2 -2
- package/templates/base/web/src/lib/auth/actions.ts.ejs +438 -15
- package/templates/base/web/src/lib/auth/config.ts.ejs +13 -0
- package/templates/base/web/src/lib/auth/cookies.ts.ejs +61 -0
- package/templates/base/web/src/lib/device/actions.ts.ejs +2 -10
- package/templates/base/web/src/lib/device/types.ts +37 -0
- package/templates/base/web/src/proxy.ts.ejs +12 -2
- package/templates/base/web/src/store/{deviceSession.store.ts.ejs → device-session-store.ts.ejs} +1 -1
- package/templates/features/mobile/auth/app/(auth)/{_layout.tsx → _layout.tsx.ejs} +7 -0
- package/templates/features/mobile/auth/app/(auth)/{login.tsx → login.tsx.ejs} +9 -8
- package/templates/features/mobile/auth/app/(auth)/{register.tsx → register.tsx.ejs} +9 -8
- package/templates/features/mobile/auth/app/(auth)/two-factor-expired.tsx.ejs +88 -0
- package/templates/features/mobile/auth/app/(auth)/two-factor.tsx.ejs +259 -0
- package/templates/features/mobile/auth/app/(public)/_layout.tsx +9 -0
- package/templates/features/mobile/{tabs/app/(tabs)/index.tsx → auth/app/(public)/index.tsx.ejs} +76 -257
- package/templates/features/mobile/auth/app/(tabs)/settings/_layout.tsx.ejs +20 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/index.tsx.ejs +137 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/_layout.tsx.ejs +35 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/index.tsx.ejs +147 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/set-password.tsx.ejs +140 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-manage.tsx.ejs +366 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-setup.tsx.ejs +317 -0
- package/templates/features/mobile/auth/app/account.tsx.ejs +331 -0
- package/templates/features/mobile/auth/app/verify-email.tsx.ejs +315 -0
- package/templates/features/mobile/auth/components/auth/{LoginForm.tsx.ejs → login-form.tsx.ejs} +15 -3
- package/templates/features/mobile/auth/components/auth/protected-screen.tsx.ejs +56 -0
- package/templates/features/mobile/auth/components/auth/{RegisterForm.tsx.ejs → register-form.tsx.ejs} +5 -3
- package/templates/features/mobile/auth/hooks/{useAuth.ts.ejs → auth.ts.ejs} +255 -1
- package/templates/features/mobile/auth/hooks/two-factor-timeout.ts.ejs +56 -0
- package/templates/features/mobile/auth/services/{deviceSession.ts → device-session.ts} +2 -10
- package/templates/features/mobile/auth/store/{deviceSession.store.ts → device-session-store.ts} +14 -5
- package/templates/features/mobile/auth/types/device-session.ts +37 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +3 -1
- package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +3 -1
- package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +6 -1
- package/templates/features/mobile/paywall/app/paywall.tsx +4 -4
- package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +0 -6
- package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx.ejs +60 -0
- package/templates/features/mobile/tabs/app/(tabs)/index.tsx.ejs +264 -0
- package/templates/features/web/auth/app/(app)/layout.tsx.ejs +8 -22
- package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +19 -3
- package/templates/features/web/auth/app/(auth)/login/two-factor/page.tsx.ejs +24 -0
- package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +117 -152
- package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/dashboard-client.tsx.ejs +41 -1
- package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/page.tsx.ejs +3 -1
- package/templates/features/web/auth/app/(protected)/layout.tsx.ejs +67 -0
- package/templates/features/web/auth/app/(protected)/settings/security/page.tsx.ejs +19 -0
- package/templates/features/web/auth/app/(protected)/settings/security/security-settings.tsx.ejs +73 -0
- package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/page.tsx.ejs +2 -6
- package/templates/features/web/auth/app/auth/callback/route.ts.ejs +5 -1
- package/templates/features/web/auth/app/auth/session-expired/route.ts.ejs +39 -0
- package/templates/features/web/auth/app/auth/two-factor-expired/route.ts.ejs +22 -0
- package/templates/features/web/auth/components/auth/login-form.tsx.ejs +18 -0
- package/templates/features/web/auth/components/auth/two-factor-verify.tsx.ejs +173 -0
- package/templates/features/web/auth/components/settings/set-password-form.tsx.ejs +123 -0
- package/templates/features/web/auth/components/settings/two-factor-manage.tsx.ejs +253 -0
- package/templates/features/web/auth/components/settings/two-factor-setup.tsx.ejs +249 -0
- package/templates/features/web/auth/components/ui/checkbox.tsx +32 -0
- package/templates/features/web/auth/components/ui/dialog.tsx +143 -0
- package/templates/features/web/auth/components/ui/input-otp.tsx +71 -0
- package/templates/integrations/mobile/adjust/store/{adjust.store.ts → adjust-store.ts} +3 -3
- package/templates/integrations/mobile/att/store/{att.store.ts → att-store.ts} +2 -2
- package/templates/integrations/mobile/revenuecat/store/{revenuecat.store.ts → revenuecat-store.ts} +1 -1
- package/templates/integrations/mobile/scate/store/{scate.store.ts → scate-store.ts} +1 -1
- package/templates/base/mobile/src/components/ui/index.ts +0 -6
- package/templates/base/mobile/src/store/index.ts.ejs +0 -18
- package/templates/base/web/src/lib/auth/index.ts.ejs +0 -40
- package/templates/features/mobile/auth/components/auth/index.ts +0 -2
- package/templates/features/mobile/auth/hooks/index.ts.ejs +0 -1
- /package/templates/base/mobile/src/services/{errorService.ts → error-service.ts} +0 -0
- /package/templates/base/mobile/src/store/{ui.store.ts → ui-store.ts} +0 -0
- /package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/sessions-client.tsx.ejs +0 -0
- /package/templates/integrations/mobile/adjust/services/{adjustService.ts.ejs → adjust-service.ts.ejs} +0 -0
- /package/templates/integrations/mobile/att/services/{attService.ts → att-service.ts} +0 -0
- /package/templates/integrations/mobile/att/services/{trackingPermissions.ts → tracking-permissions.ts} +0 -0
- /package/templates/integrations/mobile/revenuecat/services/{revenuecatService.ts.ejs → revenuecat-service.ts.ejs} +0 -0
- /package/templates/integrations/mobile/scate/services/{scateService.ts.ejs → scate-service.ts.ejs} +0 -0
|
@@ -5,8 +5,13 @@ import {
|
|
|
5
5
|
getSessionToken,
|
|
6
6
|
clearSessionCookie,
|
|
7
7
|
getDeviceSessionToken,
|
|
8
|
+
<% if (features.authentication.twoFactor) { %>
|
|
9
|
+
setTwoFactorCookie,
|
|
10
|
+
getTwoFactorToken,
|
|
11
|
+
clearTwoFactorCookie,
|
|
12
|
+
<% } %>
|
|
8
13
|
} from './cookies';
|
|
9
|
-
import { AUTH_CONFIG, BETTER_AUTH_COOKIE_NAME } from './config';
|
|
14
|
+
import { AUTH_CONFIG, BETTER_AUTH_COOKIE_NAME<% if (features.authentication.twoFactor) { %>, BETTER_AUTH_TWO_FACTOR_COOKIE_NAME, COOKIE_NAMES<% } %> } from './config';
|
|
10
15
|
|
|
11
16
|
// Types
|
|
12
17
|
export interface User {
|
|
@@ -17,6 +22,9 @@ export interface User {
|
|
|
17
22
|
image: string | null;
|
|
18
23
|
createdAt: string;
|
|
19
24
|
updatedAt: string;
|
|
25
|
+
<% if (features.authentication.twoFactor) { %>
|
|
26
|
+
twoFactorEnabled: boolean;
|
|
27
|
+
<% } %>
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
export interface Session {
|
|
@@ -39,6 +47,12 @@ interface SignInResult {
|
|
|
39
47
|
success: boolean;
|
|
40
48
|
error?: string;
|
|
41
49
|
session?: AuthSession;
|
|
50
|
+
<% if (features.authentication.emailVerification) { %>
|
|
51
|
+
needsEmailVerification?: boolean;
|
|
52
|
+
<% } %>
|
|
53
|
+
<% if (features.authentication.twoFactor) { %>
|
|
54
|
+
requiresTwoFactor?: boolean;
|
|
55
|
+
<% } %>
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
interface SignUpResult {
|
|
@@ -49,6 +63,21 @@ interface SignUpResult {
|
|
|
49
63
|
<% } %>
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
<% if (features.authentication.twoFactor) { %>
|
|
67
|
+
interface EnableTwoFactorResult {
|
|
68
|
+
success: boolean;
|
|
69
|
+
error?: string;
|
|
70
|
+
totpURI?: string;
|
|
71
|
+
backupCodes?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface VerifyTotpResult {
|
|
75
|
+
success: boolean;
|
|
76
|
+
error?: string;
|
|
77
|
+
session?: AuthSession;
|
|
78
|
+
}
|
|
79
|
+
<% } %>
|
|
80
|
+
|
|
52
81
|
/**
|
|
53
82
|
* Build headers for Fastify requests
|
|
54
83
|
* Includes session token in Better Auth cookie format and Origin for CSRF protection.
|
|
@@ -62,9 +91,24 @@ export async function buildAuthHeaders(): Promise<HeadersInit> {
|
|
|
62
91
|
'Origin': AUTH_CONFIG.appUrl,
|
|
63
92
|
};
|
|
64
93
|
|
|
94
|
+
// Build cookie header with all auth-related cookies
|
|
95
|
+
const cookieParts: string[] = [];
|
|
96
|
+
|
|
65
97
|
const sessionToken = await getSessionToken();
|
|
66
98
|
if (sessionToken) {
|
|
67
|
-
|
|
99
|
+
cookieParts.push(`${BETTER_AUTH_COOKIE_NAME}=${sessionToken}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
<% if (features.authentication.twoFactor) { %>
|
|
103
|
+
// Include two-factor cookie if present (needed for 2FA verification)
|
|
104
|
+
const twoFactorToken = await getTwoFactorToken();
|
|
105
|
+
if (twoFactorToken) {
|
|
106
|
+
cookieParts.push(`${BETTER_AUTH_TWO_FACTOR_COOKIE_NAME}=${twoFactorToken}`);
|
|
107
|
+
}
|
|
108
|
+
<% } %>
|
|
109
|
+
|
|
110
|
+
if (cookieParts.length > 0) {
|
|
111
|
+
headers['Cookie'] = cookieParts.join('; ');
|
|
68
112
|
}
|
|
69
113
|
|
|
70
114
|
const deviceToken = await getDeviceSessionToken();
|
|
@@ -87,11 +131,31 @@ export async function signIn(email: string, password: string): Promise<SignInRes
|
|
|
87
131
|
cache: 'no-store',
|
|
88
132
|
});
|
|
89
133
|
|
|
134
|
+
const data = await response.json().catch(() => ({}));
|
|
135
|
+
|
|
136
|
+
<% if (features.authentication.twoFactor) { %>
|
|
137
|
+
// Check if 2FA is required (Better Auth returns twoFactorRedirect: true)
|
|
138
|
+
if (data.twoFactorRedirect) {
|
|
139
|
+
// Extract and store temporary 2FA session token
|
|
140
|
+
const sessionToken = extractSessionToken(response);
|
|
141
|
+
if (sessionToken) {
|
|
142
|
+
await setSessionCookie(sessionToken);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Extract and store the two-factor cookie with expiration (required for TOTP verification)
|
|
146
|
+
const { token: twoFactorToken, expiresAt } = extractTwoFactorTokenAndExpiration(response);
|
|
147
|
+
if (twoFactorToken && expiresAt) {
|
|
148
|
+
await setTwoFactorCookie(twoFactorToken, expiresAt);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { success: true, requiresTwoFactor: true };
|
|
152
|
+
}
|
|
153
|
+
<% } %>
|
|
154
|
+
|
|
90
155
|
if (!response.ok) {
|
|
91
|
-
const error = await response.json().catch(() => ({}));
|
|
92
156
|
return {
|
|
93
157
|
success: false,
|
|
94
|
-
error:
|
|
158
|
+
error: data.message || 'Invalid email or password',
|
|
95
159
|
};
|
|
96
160
|
}
|
|
97
161
|
|
|
@@ -108,6 +172,17 @@ export async function signIn(email: string, password: string): Promise<SignInRes
|
|
|
108
172
|
// Get session data
|
|
109
173
|
const session = await getSession();
|
|
110
174
|
|
|
175
|
+
<% if (features.authentication.emailVerification) { %>
|
|
176
|
+
// Check if email verification is needed
|
|
177
|
+
if (session?.user?.emailVerified === false) {
|
|
178
|
+
return {
|
|
179
|
+
success: true,
|
|
180
|
+
session: session,
|
|
181
|
+
needsEmailVerification: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
<% } %>
|
|
185
|
+
|
|
111
186
|
return { success: true, session: session || undefined };
|
|
112
187
|
} catch (error) {
|
|
113
188
|
console.error('Sign in error:', error);
|
|
@@ -142,12 +217,19 @@ export async function signUp(
|
|
|
142
217
|
<% if (features.authentication.emailVerification) { %>
|
|
143
218
|
const data = await response.json();
|
|
144
219
|
|
|
145
|
-
//
|
|
146
|
-
|
|
220
|
+
// Extract and set session token (user IS logged in even if email not verified)
|
|
221
|
+
const sessionToken = extractSessionToken(response);
|
|
222
|
+
if (sessionToken) {
|
|
223
|
+
await setSessionCookie(sessionToken);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check if email verification is needed by checking user's emailVerified status
|
|
227
|
+
if (data.user?.emailVerified === false) {
|
|
147
228
|
return { success: true, needsEmailVerification: true };
|
|
148
229
|
}
|
|
149
|
-
<% } %>
|
|
150
230
|
|
|
231
|
+
return { success: true };
|
|
232
|
+
<% } else { %>
|
|
151
233
|
// Extract and set session token
|
|
152
234
|
const sessionToken = extractSessionToken(response);
|
|
153
235
|
|
|
@@ -156,6 +238,7 @@ export async function signUp(
|
|
|
156
238
|
}
|
|
157
239
|
|
|
158
240
|
return { success: true };
|
|
241
|
+
<% } %>
|
|
159
242
|
} catch (error) {
|
|
160
243
|
console.error('Sign up error:', error);
|
|
161
244
|
return { success: false, error: 'An error occurred during sign up' };
|
|
@@ -204,8 +287,10 @@ export async function getSession(): Promise<AuthSession | null> {
|
|
|
204
287
|
});
|
|
205
288
|
|
|
206
289
|
if (!response.ok) {
|
|
207
|
-
//
|
|
208
|
-
|
|
290
|
+
// Don't try to clear cookie here - it doesn't work in Server Components
|
|
291
|
+
// Server Components can't send Set-Cookie headers when using redirect()
|
|
292
|
+
// The protected layout redirects to /auth/session-expired which is a
|
|
293
|
+
// Route Handler that properly clears the cookie
|
|
209
294
|
return null;
|
|
210
295
|
}
|
|
211
296
|
|
|
@@ -275,25 +360,284 @@ export async function resetPassword(
|
|
|
275
360
|
|
|
276
361
|
<% if (features.authentication.emailVerification) { %>
|
|
277
362
|
/**
|
|
278
|
-
*
|
|
363
|
+
* Send verification OTP to email
|
|
279
364
|
*/
|
|
280
|
-
export async function
|
|
365
|
+
export async function sendVerificationOtp(email: string): Promise<{ success: boolean; error?: string }> {
|
|
281
366
|
try {
|
|
282
|
-
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/
|
|
367
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/email-otp/send-verification-otp`, {
|
|
283
368
|
method: 'POST',
|
|
284
369
|
headers: { 'Content-Type': 'application/json' },
|
|
285
|
-
body: JSON.stringify({
|
|
370
|
+
body: JSON.stringify({ email, type: 'email-verification' }),
|
|
286
371
|
cache: 'no-store',
|
|
287
372
|
});
|
|
288
373
|
|
|
289
374
|
if (!response.ok) {
|
|
290
375
|
const error = await response.json().catch(() => ({}));
|
|
291
|
-
return { success: false, error: error.message || 'Failed to
|
|
376
|
+
return { success: false, error: error.message || 'Failed to send verification code' };
|
|
292
377
|
}
|
|
293
378
|
|
|
294
379
|
return { success: true };
|
|
295
380
|
} catch (error) {
|
|
296
|
-
console.error('
|
|
381
|
+
console.error('Send OTP error:', error);
|
|
382
|
+
return { success: false, error: 'An error occurred' };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Verify email with OTP code
|
|
388
|
+
*/
|
|
389
|
+
export async function verifyEmailOtp(email: string, otp: string): Promise<{
|
|
390
|
+
success: boolean;
|
|
391
|
+
error?: string;
|
|
392
|
+
session?: AuthSession;
|
|
393
|
+
}> {
|
|
394
|
+
try {
|
|
395
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/email-otp/verify-email`, {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
headers: await buildAuthHeaders(), // Include existing session cookie
|
|
398
|
+
body: JSON.stringify({ email, otp }),
|
|
399
|
+
cache: 'no-store',
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!response.ok) {
|
|
403
|
+
const error = await response.json().catch(() => ({}));
|
|
404
|
+
return { success: false, error: error.message || 'Invalid verification code' };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Get the updated session (emailVerified is now true)
|
|
408
|
+
const session = await getSession();
|
|
409
|
+
return { success: true, session: session || undefined };
|
|
410
|
+
} catch (error) {
|
|
411
|
+
console.error('Verify OTP error:', error);
|
|
412
|
+
return { success: false, error: 'An error occurred' };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
<% } %>
|
|
416
|
+
|
|
417
|
+
<% if (features.authentication.twoFactor) { %>
|
|
418
|
+
/**
|
|
419
|
+
* Enable two-factor authentication
|
|
420
|
+
* Returns TOTP URI for QR code and backup codes
|
|
421
|
+
*/
|
|
422
|
+
export async function enableTwoFactor(password: string): Promise<EnableTwoFactorResult> {
|
|
423
|
+
try {
|
|
424
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/enable`, {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
headers: await buildAuthHeaders(),
|
|
427
|
+
body: JSON.stringify({ password }),
|
|
428
|
+
cache: 'no-store',
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (!response.ok) {
|
|
432
|
+
const error = await response.json().catch(() => ({}));
|
|
433
|
+
return { success: false, error: error.message || 'Failed to enable 2FA' };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const data = await response.json();
|
|
437
|
+
return {
|
|
438
|
+
success: true,
|
|
439
|
+
totpURI: data.totpURI,
|
|
440
|
+
backupCodes: data.backupCodes,
|
|
441
|
+
};
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error('Enable 2FA error:', error);
|
|
444
|
+
return { success: false, error: 'An error occurred' };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Verify TOTP code during 2FA setup (activates 2FA)
|
|
450
|
+
*/
|
|
451
|
+
export async function verifyTotpSetup(code: string): Promise<VerifyTotpResult> {
|
|
452
|
+
try {
|
|
453
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/verify-totp`, {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
headers: await buildAuthHeaders(),
|
|
456
|
+
body: JSON.stringify({ code }),
|
|
457
|
+
cache: 'no-store',
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
if (!response.ok) {
|
|
461
|
+
const error = await response.json().catch(() => ({}));
|
|
462
|
+
return { success: false, error: error.message || 'Invalid verification code' };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Extract and set session token after successful 2FA setup
|
|
466
|
+
const sessionToken = extractSessionToken(response);
|
|
467
|
+
if (sessionToken) {
|
|
468
|
+
await setSessionCookie(sessionToken);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const session = await getSession();
|
|
472
|
+
return { success: true, session: session || undefined };
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error('Verify TOTP setup error:', error);
|
|
475
|
+
return { success: false, error: 'An error occurred' };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Verify TOTP code during login (2FA challenge)
|
|
481
|
+
*/
|
|
482
|
+
export async function verifyTotpLogin(code: string, trustDevice?: boolean): Promise<VerifyTotpResult> {
|
|
483
|
+
try {
|
|
484
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/verify-totp`, {
|
|
485
|
+
method: 'POST',
|
|
486
|
+
headers: await buildAuthHeaders(),
|
|
487
|
+
body: JSON.stringify({ code, trustDevice }),
|
|
488
|
+
cache: 'no-store',
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (!response.ok) {
|
|
492
|
+
const error = await response.json().catch(() => ({}));
|
|
493
|
+
return { success: false, error: error.message || 'Invalid verification code' };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Extract and set session token after successful 2FA
|
|
497
|
+
const sessionToken = extractSessionToken(response);
|
|
498
|
+
if (sessionToken) {
|
|
499
|
+
await setSessionCookie(sessionToken);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Clear 2FA cookies after successful verification
|
|
503
|
+
await clearTwoFactorCookie();
|
|
504
|
+
|
|
505
|
+
const session = await getSession();
|
|
506
|
+
return { success: true, session: session || undefined };
|
|
507
|
+
} catch (error) {
|
|
508
|
+
console.error('Verify TOTP login error:', error);
|
|
509
|
+
return { success: false, error: 'An error occurred' };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Disable two-factor authentication
|
|
515
|
+
*/
|
|
516
|
+
export async function disableTwoFactor(password: string): Promise<{ success: boolean; error?: string }> {
|
|
517
|
+
try {
|
|
518
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/disable`, {
|
|
519
|
+
method: 'POST',
|
|
520
|
+
headers: await buildAuthHeaders(),
|
|
521
|
+
body: JSON.stringify({ password }),
|
|
522
|
+
cache: 'no-store',
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
const error = await response.json().catch(() => ({}));
|
|
527
|
+
return { success: false, error: error.message || 'Failed to disable 2FA' };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { success: true };
|
|
531
|
+
} catch (error) {
|
|
532
|
+
console.error('Disable 2FA error:', error);
|
|
533
|
+
return { success: false, error: 'An error occurred' };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Generate new backup codes
|
|
539
|
+
*/
|
|
540
|
+
export async function generateBackupCodes(password: string): Promise<{ success: boolean; error?: string; backupCodes?: string[] }> {
|
|
541
|
+
try {
|
|
542
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/generate-backup-codes`, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: await buildAuthHeaders(),
|
|
545
|
+
body: JSON.stringify({ password }),
|
|
546
|
+
cache: 'no-store',
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
const error = await response.json().catch(() => ({}));
|
|
551
|
+
return { success: false, error: error.message || 'Failed to generate backup codes' };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const data = await response.json();
|
|
555
|
+
return { success: true, backupCodes: data.backupCodes };
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error('Generate backup codes error:', error);
|
|
558
|
+
return { success: false, error: 'An error occurred' };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Verify backup code during login
|
|
564
|
+
*/
|
|
565
|
+
export async function verifyBackupCode(code: string): Promise<VerifyTotpResult> {
|
|
566
|
+
try {
|
|
567
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/verify-backup-code`, {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: await buildAuthHeaders(),
|
|
570
|
+
body: JSON.stringify({ code }),
|
|
571
|
+
cache: 'no-store',
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (!response.ok) {
|
|
575
|
+
const error = await response.json().catch(() => ({}));
|
|
576
|
+
return { success: false, error: error.message || 'Invalid backup code' };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const sessionToken = extractSessionToken(response);
|
|
580
|
+
if (sessionToken) {
|
|
581
|
+
await setSessionCookie(sessionToken);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Clear 2FA cookies after successful verification
|
|
585
|
+
await clearTwoFactorCookie();
|
|
586
|
+
|
|
587
|
+
const session = await getSession();
|
|
588
|
+
return { success: true, session: session || undefined };
|
|
589
|
+
} catch (error) {
|
|
590
|
+
console.error('Verify backup code error:', error);
|
|
591
|
+
return { success: false, error: 'An error occurred' };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Check if user has a password (credential account)
|
|
597
|
+
* Used to determine if OAuth user needs to set password before enabling 2FA
|
|
598
|
+
*/
|
|
599
|
+
export async function checkHasPassword(): Promise<{ hasPassword: boolean }> {
|
|
600
|
+
try {
|
|
601
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/has-password`, {
|
|
602
|
+
method: 'GET',
|
|
603
|
+
headers: await buildAuthHeaders(),
|
|
604
|
+
cache: 'no-store',
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
if (!response.ok) {
|
|
608
|
+
return { hasPassword: false };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return response.json();
|
|
612
|
+
} catch (error) {
|
|
613
|
+
console.error('Check has password error:', error);
|
|
614
|
+
return { hasPassword: false };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Set initial password for OAuth users
|
|
620
|
+
* Allows OAuth users to add a password to their account for 2FA
|
|
621
|
+
*/
|
|
622
|
+
export async function setInitialPassword(
|
|
623
|
+
newPassword: string
|
|
624
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
625
|
+
try {
|
|
626
|
+
const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/set-password`, {
|
|
627
|
+
method: 'POST',
|
|
628
|
+
headers: await buildAuthHeaders(),
|
|
629
|
+
body: JSON.stringify({ newPassword }),
|
|
630
|
+
cache: 'no-store',
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
if (!response.ok) {
|
|
634
|
+
const error = await response.json().catch(() => ({}));
|
|
635
|
+
return { success: false, error: error.message || 'Failed to set password' };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return { success: true };
|
|
639
|
+
} catch (error) {
|
|
640
|
+
console.error('Set initial password error:', error);
|
|
297
641
|
return { success: false, error: 'An error occurred' };
|
|
298
642
|
}
|
|
299
643
|
}
|
|
@@ -332,3 +676,82 @@ function extractSessionToken(response: Response): string | null {
|
|
|
332
676
|
|
|
333
677
|
return null;
|
|
334
678
|
}
|
|
679
|
+
<% if (features.authentication.twoFactor) { %>
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Extract two-factor token and expiration from Set-Cookie header
|
|
683
|
+
* Used during 2FA verification flow
|
|
684
|
+
*
|
|
685
|
+
* Extracts the absolute Expires attribute to avoid time delta issues between
|
|
686
|
+
* backend and frontend.
|
|
687
|
+
*/
|
|
688
|
+
function extractTwoFactorTokenAndExpiration(response: Response): {
|
|
689
|
+
token: string | null;
|
|
690
|
+
expiresAt: number | null; // Absolute Unix timestamp (seconds)
|
|
691
|
+
} {
|
|
692
|
+
const setCookies = response.headers.getSetCookie?.() ?? [];
|
|
693
|
+
let token: string | null = null;
|
|
694
|
+
let expiresAt: number | null = null;
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Parse expiration from a cookie string.
|
|
698
|
+
* Handles both Expires (absolute) and Max-Age (relative) attributes.
|
|
699
|
+
*
|
|
700
|
+
* IMPORTANT: We subtract a safety buffer when using Max-Age to account for
|
|
701
|
+
* the time delta between when the backend created the cookie (t_0) and when
|
|
702
|
+
* we process it (t_1). This ensures our cookie expires BEFORE the backend's,
|
|
703
|
+
* preventing edge cases where we think we have a valid token but the backend
|
|
704
|
+
* has already expired it.
|
|
705
|
+
*/
|
|
706
|
+
const SAFETY_BUFFER_SECONDS = 5;
|
|
707
|
+
|
|
708
|
+
const parseExpiration = (cookie: string): number | null => {
|
|
709
|
+
// Try Expires first (absolute timestamp) - no buffer needed since it's absolute
|
|
710
|
+
const expiresMatch = cookie.match(/Expires=([^;]+)/i);
|
|
711
|
+
if (expiresMatch) {
|
|
712
|
+
const parsedDate = new Date(expiresMatch[1]);
|
|
713
|
+
if (!isNaN(parsedDate.getTime())) {
|
|
714
|
+
return Math.floor(parsedDate.getTime() / 1000);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Fall back to Max-Age (relative seconds from now)
|
|
719
|
+
// Subtract safety buffer to ensure we expire before the backend
|
|
720
|
+
const maxAgeMatch = cookie.match(/Max-Age=(\d+)/i);
|
|
721
|
+
if (maxAgeMatch) {
|
|
722
|
+
const maxAgeSeconds = parseInt(maxAgeMatch[1], 10);
|
|
723
|
+
return Math.floor(Date.now() / 1000) + maxAgeSeconds - SAFETY_BUFFER_SECONDS;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return null;
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
if (setCookies.length === 0) {
|
|
730
|
+
const setCookieHeader = response.headers.get('set-cookie');
|
|
731
|
+
if (!setCookieHeader) return { token: null, expiresAt: null };
|
|
732
|
+
|
|
733
|
+
const tokenMatch = setCookieHeader.match(
|
|
734
|
+
new RegExp(`${BETTER_AUTH_TWO_FACTOR_COOKIE_NAME}=([^;]+)`)
|
|
735
|
+
);
|
|
736
|
+
token = tokenMatch ? tokenMatch[1] : null;
|
|
737
|
+
|
|
738
|
+
if (token) {
|
|
739
|
+
expiresAt = parseExpiration(setCookieHeader);
|
|
740
|
+
}
|
|
741
|
+
return { token, expiresAt };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
for (const cookie of setCookies) {
|
|
745
|
+
const tokenMatch = cookie.match(
|
|
746
|
+
new RegExp(`${BETTER_AUTH_TWO_FACTOR_COOKIE_NAME}=([^;]+)`)
|
|
747
|
+
);
|
|
748
|
+
if (tokenMatch) {
|
|
749
|
+
token = tokenMatch[1];
|
|
750
|
+
expiresAt = parseExpiration(cookie);
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return { token, expiresAt };
|
|
756
|
+
}
|
|
757
|
+
<% } %>
|
|
@@ -56,6 +56,12 @@ export const COOKIE_NAMES = {
|
|
|
56
56
|
|
|
57
57
|
// OAuth state for CSRF protection (temporary, during OAuth flow)
|
|
58
58
|
OAUTH_STATE: "oauth_state",
|
|
59
|
+
<% if (features.authentication.twoFactor) { %>
|
|
60
|
+
|
|
61
|
+
// Two-factor authentication cookies (temporary, during 2FA verification)
|
|
62
|
+
TWO_FACTOR: "two_factor",
|
|
63
|
+
TWO_FACTOR_EXPIRES_AT: "two_factor_expires",
|
|
64
|
+
<% } %>
|
|
59
65
|
} as const;
|
|
60
66
|
|
|
61
67
|
/**
|
|
@@ -63,3 +69,10 @@ export const COOKIE_NAMES = {
|
|
|
63
69
|
* This must match the cookie name that Better Auth uses
|
|
64
70
|
*/
|
|
65
71
|
export const BETTER_AUTH_COOKIE_NAME = "better-auth.session_token";
|
|
72
|
+
<% if (features.authentication.twoFactor) { %>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Better Auth two-factor cookie name (used during 2FA verification)
|
|
76
|
+
*/
|
|
77
|
+
export const BETTER_AUTH_TWO_FACTOR_COOKIE_NAME = "better-auth.two_factor";
|
|
78
|
+
<% } %>
|
|
@@ -72,3 +72,64 @@ export async function clearDeviceSessionCookie(): Promise<void> {
|
|
|
72
72
|
const cookieStore = await cookies();
|
|
73
73
|
cookieStore.delete(COOKIE_NAMES.DEVICE_SESSION);
|
|
74
74
|
}
|
|
75
|
+
<% if (features.authentication.twoFactor) { %>
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Set the two-factor cookie for 2FA verification
|
|
79
|
+
* Stores both the token and expiration timestamp for client-side timeout handling
|
|
80
|
+
*
|
|
81
|
+
* Uses absolute expiration (expires) instead of relative (maxAge) to ensure
|
|
82
|
+
* frontend cookies expire at exactly the same time as backend cookies.
|
|
83
|
+
*/
|
|
84
|
+
export async function setTwoFactorCookie(
|
|
85
|
+
token: string,
|
|
86
|
+
expiresAt: number // Absolute Unix timestamp (seconds)
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const cookieStore = await cookies();
|
|
89
|
+
const expiresDate = new Date(expiresAt * 1000); // Convert to Date object
|
|
90
|
+
|
|
91
|
+
// Store the token (httpOnly for security)
|
|
92
|
+
cookieStore.set(COOKIE_NAMES.TWO_FACTOR, token, {
|
|
93
|
+
httpOnly: true,
|
|
94
|
+
secure: process.env.NODE_ENV === 'production',
|
|
95
|
+
sameSite: 'lax',
|
|
96
|
+
path: '/',
|
|
97
|
+
expires: expiresDate, // Use absolute expiration directly
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Store expiration timestamp (readable by client for timeout handling)
|
|
101
|
+
cookieStore.set(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT, expiresAt.toString(), {
|
|
102
|
+
httpOnly: false,
|
|
103
|
+
secure: process.env.NODE_ENV === 'production',
|
|
104
|
+
sameSite: 'lax',
|
|
105
|
+
path: '/',
|
|
106
|
+
expires: expiresDate, // Same absolute expiration
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the two-factor token from cookies
|
|
112
|
+
*/
|
|
113
|
+
export async function getTwoFactorToken(): Promise<string | undefined> {
|
|
114
|
+
const cookieStore = await cookies();
|
|
115
|
+
return cookieStore.get(COOKIE_NAMES.TWO_FACTOR)?.value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the two-factor expiration timestamp from cookies
|
|
120
|
+
*/
|
|
121
|
+
export async function getTwoFactorExpiresAt(): Promise<number | undefined> {
|
|
122
|
+
const cookieStore = await cookies();
|
|
123
|
+
const value = cookieStore.get(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT)?.value;
|
|
124
|
+
return value ? parseInt(value, 10) : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Clear all two-factor cookies
|
|
129
|
+
*/
|
|
130
|
+
export async function clearTwoFactorCookie(): Promise<void> {
|
|
131
|
+
const cookieStore = await cookies();
|
|
132
|
+
cookieStore.delete(COOKIE_NAMES.TWO_FACTOR);
|
|
133
|
+
cookieStore.delete(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT);
|
|
134
|
+
}
|
|
135
|
+
<% } %>
|
|
@@ -7,16 +7,8 @@ import {
|
|
|
7
7
|
} from '../auth/cookies';
|
|
8
8
|
import { AUTH_CONFIG } from '../auth/config';
|
|
9
9
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
deviceId: string;
|
|
13
|
-
sessionToken: string;
|
|
14
|
-
createdAt: string;
|
|
15
|
-
lastActiveAt: string;
|
|
16
|
-
migrated: boolean;
|
|
17
|
-
migratedToUserId: string | null;
|
|
18
|
-
preferredCurrency: string;
|
|
19
|
-
}
|
|
10
|
+
export type { DeviceSession } from './types';
|
|
11
|
+
import type { DeviceSession } from './types';
|
|
20
12
|
|
|
21
13
|
/**
|
|
22
14
|
* Create a new device session
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Base session properties shared by all states
|
|
2
|
+
interface BaseDeviceSession {
|
|
3
|
+
id: string;
|
|
4
|
+
deviceId: string;
|
|
5
|
+
sessionToken: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
lastActiveAt: string;
|
|
8
|
+
preferredCurrency: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Active (non-migrated) session
|
|
12
|
+
interface ActiveDeviceSession extends BaseDeviceSession {
|
|
13
|
+
migrated: false;
|
|
14
|
+
migratedToUserId: null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Migrated session (device was linked to a user account)
|
|
18
|
+
interface MigratedDeviceSession extends BaseDeviceSession {
|
|
19
|
+
migrated: true;
|
|
20
|
+
migratedToUserId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Discriminated union - TypeScript will narrow based on `migrated` field
|
|
24
|
+
export type DeviceSession = ActiveDeviceSession | MigratedDeviceSession;
|
|
25
|
+
|
|
26
|
+
// Type guards for explicit narrowing when needed
|
|
27
|
+
export function isMigratedSession(
|
|
28
|
+
session: DeviceSession
|
|
29
|
+
): session is MigratedDeviceSession {
|
|
30
|
+
return session.migrated === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isActiveSession(
|
|
34
|
+
session: DeviceSession
|
|
35
|
+
): session is ActiveDeviceSession {
|
|
36
|
+
return session.migrated === false;
|
|
37
|
+
}
|