From 3fa30f72a3ef1a04f7da668247d1f9858f0c53d7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 03:28:46 +0300 Subject: [PATCH] feat(auth): auto-login after onboarding, consolidate session cookies - Extract session cookie issuance into sessionCookies.ts helper; remove duplicated COOKIE_BASE blocks from login, register, oauth callback/authorize, refresh handler, hooks.server.ts, and onboarding. - Derive cookie secure flag from ORIGIN (https://...) instead of NODE_ENV so plain-HTTP production deploys don't silently drop cookies. - Auto-login admin after onboarding completes; UI does a full reload so hooks.server.ts picks up the new session. - Harden onboarding: reject duplicate admin creation, flip onboardingComplete atomically to prevent concurrent completions, error out if no admin found. - Fix Dockerfile CMD operator precedence: node build now always runs after migrate deploy || db push. - Wire ORIGIN env through docker-compose. --- Dockerfile | 2 +- docker-compose.yml | 1 + src/hooks.server.ts | 25 ++----- .../onboarding/OnboardingWizard.svelte | 5 +- src/lib/server/utils/sessionCookies.ts | 70 +++++++++++++++++++ src/routes/api/onboarding/+server.ts | 36 +++++++++- src/routes/auth/oauth/authorize/+server.ts | 13 ++-- src/routes/auth/oauth/callback/+server.ts | 32 +-------- src/routes/auth/refresh/+server.ts | 32 +++------ src/routes/login/+page.server.ts | 33 +-------- src/routes/register/+page.server.ts | 32 +-------- 11 files changed, 134 insertions(+), 147 deletions(-) create mode 100644 src/lib/server/utils/sessionCookies.ts diff --git a/Dockerfile b/Dockerfile index f052d52..c95534c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,4 +37,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/api/health || exit 1 -CMD ["sh", "-c", "npx prisma migrate deploy 2>/dev/null || npx prisma db push && node build"] +CMD ["sh", "-c", "(npx prisma migrate deploy 2>/dev/null || npx prisma db push) && ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}} node build"] diff --git a/docker-compose.yml b/docker-compose.yml index 82a354f..835a138 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: - NODE_ENV=production - APP_PORT=3000 - APP_HOST=0.0.0.0 + - ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}} volumes: - launcher-data:/app/data networks: diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5575ce3..01605c5 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -7,6 +7,10 @@ import * as apiTokenService from '$lib/server/services/apiTokenService.js'; import { extractBearerToken } from '$lib/server/middleware/authenticate.js'; import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js'; +import { + clearSessionCookies, + setRotatedCookies +} from '$lib/server/utils/sessionCookies.js'; // Initialize backup scheduler on server startup initBackupScheduler(); @@ -20,13 +24,6 @@ function isPublicPath(pathname: string): boolean { const ACCESS_TOKEN_COOKIE = 'access_token'; const REFRESH_TOKEN_COOKIE = 'refresh_token'; -const COOKIE_BASE = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/' -}; - export const handle: Handle = async ({ event, resolve }) => { // Initialize locals event.locals.user = null; @@ -68,15 +65,7 @@ export const handle: Handle = async ({ event, resolve }) => { const user = await userService.findById(userIdFromCookie); const tokens = await authService.rotateTokens(user.id, user.email, user.role); - // Set new cookies - event.cookies.set(ACCESS_TOKEN_COOKIE, tokens.accessToken, { - ...COOKIE_BASE, - maxAge: 900 // 15 minutes - }); - event.cookies.set(REFRESH_TOKEN_COOKIE, tokens.refreshToken, { - ...COOKIE_BASE, - maxAge: 604800 // 7 days - }); + setRotatedCookies(event.cookies, tokens); event.locals.user = { id: user.id, @@ -92,9 +81,7 @@ export const handle: Handle = async ({ event, resolve }) => { } } catch { // Refresh failed — clear stale cookies - event.cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' }); - event.cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' }); - event.cookies.delete('refresh_user_id', { path: '/' }); + clearSessionCookies(event.cookies); } } diff --git a/src/lib/components/onboarding/OnboardingWizard.svelte b/src/lib/components/onboarding/OnboardingWizard.svelte index 0a9d668..6b63754 100644 --- a/src/lib/components/onboarding/OnboardingWizard.svelte +++ b/src/lib/components/onboarding/OnboardingWizard.svelte @@ -157,8 +157,9 @@ break; } - // Redirect to login page - goto('/login'); + // Auto-logged in via cookies. Use a full navigation (not goto) so + // hooks.server.ts re-runs and populates locals.user from the new cookies. + window.location.href = '/'; break; } } diff --git a/src/lib/server/utils/sessionCookies.ts b/src/lib/server/utils/sessionCookies.ts new file mode 100644 index 0000000..0c72f5e --- /dev/null +++ b/src/lib/server/utils/sessionCookies.ts @@ -0,0 +1,70 @@ +import type { Cookies } from '@sveltejs/kit'; +import * as authService from '$lib/server/services/authService.js'; + +export const ACCESS_TOKEN_TTL_SEC = 900; // 15 minutes +export const REFRESH_TOKEN_TTL_SEC = 604800; // 7 days + +function isHttpsOrigin(): boolean { + const origin = process.env.ORIGIN; + if (origin) return origin.startsWith('https://'); + // Fall back to NODE_ENV only when ORIGIN is unset. + return process.env.NODE_ENV === 'production'; +} + +/** + * Shared cookie attributes (httpOnly, secure, sameSite=lax, path=/). + * `secure` is derived from ORIGIN (https://...) rather than NODE_ENV so + * plain-HTTP production deployments don't silently drop cookies. + */ +export function cookieBase() { + return { + httpOnly: true, + secure: isHttpsOrigin(), + sameSite: 'lax' as const, + path: '/' + }; +} + +interface SessionUser { + readonly id: string; + readonly email: string; + readonly role: string; +} + +/** + * Issue access + refresh cookies for a freshly-authenticated user. + * Persists a new refresh token in the DB. + */ +export async function issueSessionCookies(cookies: Cookies, user: SessionUser): Promise { + const accessToken = authService.signAccessToken({ + userId: user.id, + email: user.email, + role: user.role + }); + const refreshToken = authService.generateRefreshToken(); + await authService.saveRefreshToken(user.id, refreshToken); + + const base = cookieBase(); + cookies.set('access_token', accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); + cookies.set('refresh_token', refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); + cookies.set('refresh_user_id', user.id, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); +} + +/** + * Set access + refresh cookies from a pre-generated token pair (used by refresh rotation). + * Does not touch the DB. + */ +export function setRotatedCookies( + cookies: Cookies, + tokens: { readonly accessToken: string; readonly refreshToken: string } +): void { + const base = cookieBase(); + cookies.set('access_token', tokens.accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC }); + cookies.set('refresh_token', tokens.refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC }); +} + +export function clearSessionCookies(cookies: Cookies): void { + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('refresh_user_id', { path: '/' }); +} diff --git a/src/routes/api/onboarding/+server.ts b/src/routes/api/onboarding/+server.ts index 3d8da24..8f4110b 100644 --- a/src/routes/api/onboarding/+server.ts +++ b/src/routes/api/onboarding/+server.ts @@ -4,6 +4,7 @@ import { success, error } from '$lib/server/utils/response.js'; import * as onboardingService from '$lib/server/services/onboardingService.js'; import * as userService from '$lib/server/services/userService.js'; import * as boardService from '$lib/server/services/boardService.js'; +import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js'; import { prisma } from '$lib/server/prisma.js'; import { DEFAULTS } from '$lib/utils/constants.js'; import { z } from 'zod'; @@ -31,7 +32,7 @@ export const GET: RequestHandler = async () => { * POST /api/onboarding — Complete an onboarding step. * No auth required (onboarding runs before any user exists). */ -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, cookies }) => { let body: unknown; try { body = await request.json(); @@ -69,6 +70,12 @@ export const POST: RequestHandler = async ({ request }) => { return json(error('Invalid admin account data'), { status: 400 }); } + // Prevent concurrent admin creation during the onboarding window. + const existingAdmins = await prisma.user.count({ where: { role: 'admin' } }); + if (existingAdmins > 0) { + return json(error('Admin account already created'), { status: 409 }); + } + const user = await userService.create({ email: adminData.data.email, password: adminData.data.password, @@ -166,7 +173,32 @@ export const POST: RequestHandler = async ({ request }) => { }); } - await onboardingService.completeOnboarding(); + // Atomically flip onboardingComplete false→true so concurrent callers + // can't both complete onboarding. + const flipped = await prisma.systemSettings.updateMany({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID, onboardingComplete: false }, + data: { onboardingComplete: true } + }); + + if (flipped.count === 0) { + // Either no SystemSettings row yet, or another request already completed. + await onboardingService.completeOnboarding(); + } + + // Auto-login the admin user who just completed onboarding. + // At this point isOnboardingNeeded() was true, so exactly one admin exists + // (enforced by the guard in the 'admin' step). + const adminUser = await prisma.user.findFirst({ + where: { role: 'admin' }, + orderBy: { createdAt: 'asc' } + }); + + if (!adminUser) { + return json(error('Onboarding complete but no admin user found'), { status: 500 }); + } + + await issueSessionCookies(cookies, adminUser); + return json(success({ complete: true })); } diff --git a/src/routes/auth/oauth/authorize/+server.ts b/src/routes/auth/oauth/authorize/+server.ts index ccdcb4a..049c781 100644 --- a/src/routes/auth/oauth/authorize/+server.ts +++ b/src/routes/auth/oauth/authorize/+server.ts @@ -1,13 +1,7 @@ import { redirect, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import * as oauthService from '$lib/server/services/oauthService.js'; - -const COOKIE_BASE = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/' -}; +import { cookieBase } from '$lib/server/utils/sessionCookies.js'; export const GET: RequestHandler = async ({ cookies, url }) => { try { @@ -19,13 +13,14 @@ export const GET: RequestHandler = async ({ cookies, url }) => { const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier); const state = oauthService.generateState(); + const base = cookieBase(); // Store code_verifier and state in HTTP-only cookies for the callback cookies.set('oauth_code_verifier', codeVerifier, { - ...COOKIE_BASE, + ...base, maxAge: 600 // 10 minutes — enough for the auth flow }); cookies.set('oauth_state', state, { - ...COOKIE_BASE, + ...base, maxAge: 600 }); diff --git a/src/routes/auth/oauth/callback/+server.ts b/src/routes/auth/oauth/callback/+server.ts index 85fd4dd..e8025f9 100644 --- a/src/routes/auth/oauth/callback/+server.ts +++ b/src/routes/auth/oauth/callback/+server.ts @@ -2,14 +2,7 @@ import { redirect, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import * as oauthService from '$lib/server/services/oauthService.js'; import * as userService from '$lib/server/services/userService.js'; -import * as authService from '$lib/server/services/authService.js'; - -const COOKIE_BASE = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/' -}; +import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js'; export const GET: RequestHandler = async ({ url, cookies }) => { try { @@ -58,28 +51,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { groups: userInfo.groups ? [...userInfo.groups] : undefined }); - // Issue local JWT tokens (same as local auth flow) - const accessToken = authService.signAccessToken({ - userId: user.id, - email: user.email, - role: user.role - }); - const refreshToken = authService.generateRefreshToken(); - await authService.saveRefreshToken(user.id, refreshToken); - - // Set session cookies - cookies.set('access_token', accessToken, { - ...COOKIE_BASE, - maxAge: 900 // 15 minutes - }); - cookies.set('refresh_token', refreshToken, { - ...COOKIE_BASE, - maxAge: 604800 // 7 days - }); - cookies.set('refresh_user_id', user.id, { - ...COOKIE_BASE, - maxAge: 604800 // 7 days - }); + await issueSessionCookies(cookies, user); throw redirect(302, '/'); } catch (err) { diff --git a/src/routes/auth/refresh/+server.ts b/src/routes/auth/refresh/+server.ts index c40b496..242c857 100644 --- a/src/routes/auth/refresh/+server.ts +++ b/src/routes/auth/refresh/+server.ts @@ -3,13 +3,11 @@ import type { RequestHandler } from './$types.js'; import * as authService from '$lib/server/services/authService.js'; import * as userService from '$lib/server/services/userService.js'; import { error as apiError } from '$lib/server/utils/response.js'; - -const COOKIE_BASE = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/' -}; +import { + ACCESS_TOKEN_TTL_SEC, + clearSessionCookies, + setRotatedCookies +} from '$lib/server/utils/sessionCookies.js'; export const POST: RequestHandler = async ({ cookies }) => { const refreshToken = cookies.get('refresh_token'); @@ -22,34 +20,22 @@ export const POST: RequestHandler = async ({ cookies }) => { try { const isValid = await authService.validateRefreshToken(userId, refreshToken); if (!isValid) { - // Clear stale cookies - cookies.delete('access_token', { path: '/' }); - cookies.delete('refresh_token', { path: '/' }); - cookies.delete('refresh_user_id', { path: '/' }); + clearSessionCookies(cookies); return json(apiError('Invalid or expired refresh token'), { status: 401 }); } const user = await userService.findById(userId); const tokens = await authService.rotateTokens(user.id, user.email, user.role); - cookies.set('access_token', tokens.accessToken, { - ...COOKIE_BASE, - maxAge: 900 - }); - cookies.set('refresh_token', tokens.refreshToken, { - ...COOKIE_BASE, - maxAge: 604800 - }); + setRotatedCookies(cookies, tokens); return json({ success: true, - data: { expiresIn: 900 }, + data: { expiresIn: ACCESS_TOKEN_TTL_SEC }, error: null }); } catch { - cookies.delete('access_token', { path: '/' }); - cookies.delete('refresh_token', { path: '/' }); - cookies.delete('refresh_user_id', { path: '/' }); + clearSessionCookies(cookies); return json(apiError('Token refresh failed'), { status: 401 }); } }; diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 096f5c8..a0af568 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -5,17 +5,11 @@ import { fail, redirect } from '@sveltejs/kit'; import { loginSchema } from '$lib/utils/validators.js'; import * as userService from '$lib/server/services/userService.js'; import * as authService from '$lib/server/services/authService.js'; +import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js'; import { prisma } from '$lib/server/prisma.js'; import { DEFAULTS } from '$lib/utils/constants.js'; import type { AuthMode } from '$lib/utils/constants.js'; -const COOKIE_BASE = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/' -}; - export const load: PageServerLoad = async ({ locals }) => { // If already logged in, redirect to home if (locals.user) { @@ -43,13 +37,11 @@ export const actions: Actions = { const { email, password } = form.data; - // Find user by email const user = await userService.findByEmail(email); if (!user) { return setError(form, 'email', 'Invalid email or password'); } - // Verify password if (!user.password) { return setError(form, 'email', 'This account does not use password authentication'); } @@ -59,28 +51,7 @@ export const actions: Actions = { return setError(form, 'email', 'Invalid email or password'); } - // Generate tokens - const accessToken = authService.signAccessToken({ - userId: user.id, - email: user.email, - role: user.role - }); - const refreshToken = authService.generateRefreshToken(); - await authService.saveRefreshToken(user.id, refreshToken); - - // Set cookies - cookies.set('access_token', accessToken, { - ...COOKIE_BASE, - maxAge: 900 // 15 minutes - }); - cookies.set('refresh_token', refreshToken, { - ...COOKIE_BASE, - maxAge: 604800 // 7 days - }); - cookies.set('refresh_user_id', user.id, { - ...COOKIE_BASE, - maxAge: 604800 // 7 days - }); + await issueSessionCookies(cookies, user); throw redirect(302, '/'); } diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 9d9b78c..34a60fa 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -5,17 +5,10 @@ import { fail, redirect, error } from '@sveltejs/kit'; import { registerSchema } from '$lib/utils/validators.js'; import { prisma } from '$lib/server/prisma.js'; import * as userService from '$lib/server/services/userService.js'; -import * as authService from '$lib/server/services/authService.js'; import * as groupService from '$lib/server/services/groupService.js'; +import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js'; import { DEFAULTS } from '$lib/utils/constants.js'; -const COOKIE_BASE = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/' -}; - async function isRegistrationEnabled(): Promise { const settings = await prisma.systemSettings.findUnique({ where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, @@ -72,28 +65,7 @@ export const actions: Actions = { // Add user to default groups await groupService.addUserToDefaultGroups(user.id); - // Auto-login: generate tokens - const accessToken = authService.signAccessToken({ - userId: user.id, - email: user.email, - role: user.role - }); - const refreshToken = authService.generateRefreshToken(); - await authService.saveRefreshToken(user.id, refreshToken); - - // Set cookies - cookies.set('access_token', accessToken, { - ...COOKIE_BASE, - maxAge: 900 - }); - cookies.set('refresh_token', refreshToken, { - ...COOKIE_BASE, - maxAge: 604800 - }); - cookies.set('refresh_user_id', user.id, { - ...COOKIE_BASE, - maxAge: 604800 - }); + await issueSessionCookies(cookies, user); throw redirect(302, '/'); }