feat(auth): auto-login after onboarding, consolidate session cookies
Lint & Test / lint-and-check (push) Failing after 5m1s
Lint & Test / test (push) Has been skipped

- 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.
This commit is contained in:
2026-04-16 03:28:46 +03:00
parent 2c9c36605d
commit 3fa30f72a3
11 changed files with 134 additions and 147 deletions
+1 -1
View File
@@ -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"]
+1
View File
@@ -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:
+6 -19
View File
@@ -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);
}
}
@@ -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;
}
}
+70
View File
@@ -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<void> {
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: '/' });
}
+33 -1
View File
@@ -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 }) => {
});
}
// 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 }));
}
+4 -9
View File
@@ -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
});
+2 -30
View File
@@ -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) {
+9 -23
View File
@@ -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 });
}
};
+2 -31
View File
@@ -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, '/');
}
+2 -30
View File
@@ -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<boolean> {
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, '/');
}