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.
This commit is contained in:
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '/' });
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user