From 15235ef6b2428d17fed07691c4d41e9bac9982b0 Mon Sep 17 00:00:00 2001 From: "Claude frontend-developer (Opus 4.6)" Date: Mon, 9 Feb 2026 04:29:53 +0000 Subject: [PATCH] feat(auth): implement local admin setup, login, and auth UI Add backend authentication endpoints (GET /api/auth/me, POST /api/auth/setup, POST /api/auth/login) with argon2 password hashing and timing-attack prevention. Create frontend SetupPage and LoginPage with client-side validation, accessible forms, and error handling. Add auth API client module and update routing. Backend: - userService with createLocalUser, verifyPassword, findByEmail, countUsers - Auth routes with JSON schema validation (email format, password min 12 chars) - Config: SESSION_DURATION and SECURE_COOKIES environment variables - argon2@0.43.0 for OWASP-recommended password hashing Frontend: - SetupPage: centered card form for initial admin account creation - LoginPage: centered card form for email/password login - authApi.ts: typed API client for auth endpoints - Routes /setup and /login outside AppShell (no sidebar) - Webpack extensionAlias for ESM .js -> .ts resolution Tests: 65 new tests (userService unit + auth routes integration) All quality gates pass, 281 tests total across 22 suites. Fixes #30 Co-Authored-By: Claude backend-developer (Sonnet 4.5) Co-Authored-By: Claude frontend-developer (Sonnet 4.5) Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) --- .env.example | 4 + client/src/App.tsx | 23 +- client/src/lib/authApi.ts | 39 + .../src/pages/LoginPage/LoginPage.module.css | 142 ++++ client/src/pages/LoginPage/LoginPage.tsx | 137 ++++ .../src/pages/SetupPage/SetupPage.module.css | 147 ++++ client/src/pages/SetupPage/SetupPage.tsx | 198 +++++ client/webpack.config.cjs | 4 + package-lock.json | 45 ++ server/package.json | 1 + server/src/app.ts | 4 + server/src/plugins/config.test.ts | 8 + server/src/plugins/config.ts | 27 + server/src/routes/auth.test.ts | 733 ++++++++++++++++++ server/src/routes/auth.ts | 135 ++++ server/src/services/userService.test.ts | 633 +++++++++++++++ server/src/services/userService.ts | 118 +++ 17 files changed, 2397 insertions(+), 1 deletion(-) create mode 100644 client/src/lib/authApi.ts create mode 100644 client/src/pages/LoginPage/LoginPage.module.css create mode 100644 client/src/pages/LoginPage/LoginPage.tsx create mode 100644 client/src/pages/SetupPage/SetupPage.module.css create mode 100644 client/src/pages/SetupPage/SetupPage.tsx create mode 100644 server/src/routes/auth.test.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/services/userService.test.ts create mode 100644 server/src/services/userService.ts diff --git a/.env.example b/.env.example index 93d38e7f4..39988ef4d 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ NODE_ENV=production # ─── Database ────────────────────────────────────────── DATABASE_URL=/app/data/cornerstone.db + +# ─── Session ─────────────────────────────────────────── +SESSION_DURATION=604800 +SECURE_COOKIES=true diff --git a/client/src/App.tsx b/client/src/App.tsx index d7939cc9f..ecb4bb627 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,7 +1,9 @@ -import { lazy } from 'react'; +import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AppShell } from './components/AppShell/AppShell'; +const SetupPage = lazy(() => import('./pages/SetupPage/SetupPage')); +const LoginPage = lazy(() => import('./pages/LoginPage/LoginPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage/DashboardPage')); const WorkItemsPage = lazy(() => import('./pages/WorkItemsPage/WorkItemsPage')); const BudgetPage = lazy(() => import('./pages/BudgetPage/BudgetPage')); @@ -14,6 +16,25 @@ export function App() { return ( + {/* Auth routes (no AppShell wrapper) */} + Loading...}> + + + } + /> + Loading...}> + + + } + /> + + {/* App routes (with AppShell wrapper) */} }> } /> } /> diff --git a/client/src/lib/authApi.ts b/client/src/lib/authApi.ts new file mode 100644 index 000000000..40570c1b0 --- /dev/null +++ b/client/src/lib/authApi.ts @@ -0,0 +1,39 @@ +import { get, post } from './apiClient.js'; +import type { UserResponse } from '@cornerstone/shared'; + +export interface AuthMeResponse { + user: UserResponse | null; + setupRequired: boolean; + oidcEnabled: boolean; +} + +export interface SetupResponse { + user: UserResponse; +} + +export interface LoginResponse { + user: UserResponse; +} + +export interface SetupPayload { + email: string; + displayName: string; + password: string; +} + +export interface LoginPayload { + email: string; + password: string; +} + +export function getAuthMe(): Promise { + return get('/auth/me'); +} + +export function setup(payload: SetupPayload): Promise { + return post('/auth/setup', payload); +} + +export function login(payload: LoginPayload): Promise { + return post('/auth/login', payload); +} diff --git a/client/src/pages/LoginPage/LoginPage.module.css b/client/src/pages/LoginPage/LoginPage.module.css new file mode 100644 index 000000000..0edb78671 --- /dev/null +++ b/client/src/pages/LoginPage/LoginPage.module.css @@ -0,0 +1,142 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background-color: #f9fafb; + padding: 1rem; +} + +.card { + background: white; + border-radius: 0.5rem; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.1), + 0 1px 2px rgba(0, 0, 0, 0.06); + padding: 2rem; + width: 100%; + max-width: 28rem; +} + +.title { + font-size: 1.875rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.5rem; + text-align: center; +} + +.description { + font-size: 0.875rem; + color: #4b5563; + text-align: center; + margin-bottom: 1.5rem; +} + +.errorBanner { + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.375rem; + color: #991b1b; + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.successBanner { + background-color: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 0.375rem; + color: #166534; + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.label { + font-size: 0.875rem; + font-weight: 500; + color: #111827; +} + +.input { + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.875rem; + color: #111827; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.input:disabled { + background-color: #f9fafb; + cursor: not-allowed; +} + +.input[aria-invalid='true'] { + border-color: #ef4444; +} + +.error { + font-size: 0.75rem; + color: #dc2626; +} + +.button { + margin-top: 0.5rem; + padding: 0.625rem 1rem; + background-color: #3b82f6; + color: white; + font-size: 0.875rem; + font-weight: 500; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.button:hover:not(:disabled) { + background-color: #2563eb; +} + +.button:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +.button:disabled { + background-color: #9ca3af; + cursor: not-allowed; +} + +@media (max-width: 767px) { + .card { + padding: 1.5rem; + } + + .title { + font-size: 1.5rem; + } +} diff --git a/client/src/pages/LoginPage/LoginPage.tsx b/client/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 000000000..286dc0be8 --- /dev/null +++ b/client/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,137 @@ +import { useState, type FormEvent } from 'react'; +import { login } from '../../lib/authApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import styles from './LoginPage.module.css'; + +interface FormErrors { + email?: string; + password?: string; +} + +export function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [errors, setErrors] = useState({}); + const [apiError, setApiError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!email) { + newErrors.email = 'Email is required'; + } + + if (!password) { + newErrors.password = 'Password is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setApiError(''); + setSuccessMessage(''); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + try { + const response = await login({ email, password }); + setSuccessMessage( + `Login successful! Welcome back, ${response.user.displayName}. Session management will be implemented in Story #32.`, + ); + // Clear form + setEmail(''); + setPassword(''); + setErrors({}); + } catch (error) { + if (error instanceof ApiClientError) { + setApiError(error.error.message); + } else { + setApiError('An unexpected error occurred. Please try again.'); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

Sign In

+

Sign in to your Cornerstone account.

+ + {apiError && ( +
+ {apiError} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + +
+
+ + setEmail(e.target.value)} + className={styles.input} + aria-invalid={!!errors.email} + aria-describedby={errors.email ? 'email-error' : undefined} + disabled={isSubmitting} + autoComplete="email" + /> + {errors.email && ( + + {errors.email} + + )} +
+ +
+ + setPassword(e.target.value)} + className={styles.input} + aria-invalid={!!errors.password} + aria-describedby={errors.password ? 'password-error' : undefined} + disabled={isSubmitting} + autoComplete="current-password" + /> + {errors.password && ( + + {errors.password} + + )} +
+ + +
+
+
+ ); +} + +export default LoginPage; diff --git a/client/src/pages/SetupPage/SetupPage.module.css b/client/src/pages/SetupPage/SetupPage.module.css new file mode 100644 index 000000000..e94df1d80 --- /dev/null +++ b/client/src/pages/SetupPage/SetupPage.module.css @@ -0,0 +1,147 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background-color: #f9fafb; + padding: 1rem; +} + +.card { + background: white; + border-radius: 0.5rem; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.1), + 0 1px 2px rgba(0, 0, 0, 0.06); + padding: 2rem; + width: 100%; + max-width: 28rem; +} + +.title { + font-size: 1.875rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.5rem; + text-align: center; +} + +.description { + font-size: 0.875rem; + color: #4b5563; + text-align: center; + margin-bottom: 1.5rem; +} + +.errorBanner { + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.375rem; + color: #991b1b; + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.successBanner { + background-color: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 0.375rem; + color: #166534; + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.label { + font-size: 0.875rem; + font-weight: 500; + color: #111827; +} + +.input { + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.875rem; + color: #111827; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.input:disabled { + background-color: #f9fafb; + cursor: not-allowed; +} + +.input[aria-invalid='true'] { + border-color: #ef4444; +} + +.error { + font-size: 0.75rem; + color: #dc2626; +} + +.hint { + font-size: 0.75rem; + color: #6b7280; +} + +.button { + margin-top: 0.5rem; + padding: 0.625rem 1rem; + background-color: #3b82f6; + color: white; + font-size: 0.875rem; + font-weight: 500; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.button:hover:not(:disabled) { + background-color: #2563eb; +} + +.button:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +.button:disabled { + background-color: #9ca3af; + cursor: not-allowed; +} + +@media (max-width: 767px) { + .card { + padding: 1.5rem; + } + + .title { + font-size: 1.5rem; + } +} diff --git a/client/src/pages/SetupPage/SetupPage.tsx b/client/src/pages/SetupPage/SetupPage.tsx new file mode 100644 index 000000000..2c0322696 --- /dev/null +++ b/client/src/pages/SetupPage/SetupPage.tsx @@ -0,0 +1,198 @@ +import { useState, type FormEvent } from 'react'; +import { setup } from '../../lib/authApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import styles from './SetupPage.module.css'; + +interface FormErrors { + email?: string; + displayName?: string; + password?: string; + confirmPassword?: string; +} + +export function SetupPage() { + const [email, setEmail] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [errors, setErrors] = useState({}); + const [apiError, setApiError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!email) { + newErrors.email = 'Email is required'; + } + + if (!displayName) { + newErrors.displayName = 'Display name is required'; + } else if (displayName.length < 1 || displayName.length > 100) { + newErrors.displayName = 'Display name must be between 1 and 100 characters'; + } + + if (!password) { + newErrors.password = 'Password is required'; + } else if (password.length < 12) { + newErrors.password = 'Password must be at least 12 characters'; + } + + if (password !== confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setApiError(''); + setSuccessMessage(''); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + try { + const response = await setup({ email, displayName, password }); + setSuccessMessage( + `Setup complete! Admin account created for ${response.user.email}. Session management will be implemented in Story #32.`, + ); + // Clear form + setEmail(''); + setDisplayName(''); + setPassword(''); + setConfirmPassword(''); + setErrors({}); + } catch (error) { + if (error instanceof ApiClientError) { + setApiError(error.error.message); + } else { + setApiError('An unexpected error occurred. Please try again.'); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

Initial Setup

+

+ Create the admin account to get started with Cornerstone. +

+ + {apiError && ( +
+ {apiError} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + +
+
+ + setEmail(e.target.value)} + className={styles.input} + aria-invalid={!!errors.email} + aria-describedby={errors.email ? 'email-error' : undefined} + disabled={isSubmitting} + /> + {errors.email && ( + + {errors.email} + + )} +
+ +
+ + setDisplayName(e.target.value)} + className={styles.input} + aria-invalid={!!errors.displayName} + aria-describedby={errors.displayName ? 'displayName-error' : undefined} + disabled={isSubmitting} + /> + {errors.displayName && ( + + {errors.displayName} + + )} +
+ +
+ + setPassword(e.target.value)} + className={styles.input} + aria-invalid={!!errors.password} + aria-describedby={errors.password ? 'password-error' : undefined} + disabled={isSubmitting} + /> + {errors.password && ( + + {errors.password} + + )} + Minimum 12 characters +
+ +
+ + setConfirmPassword(e.target.value)} + className={styles.input} + aria-invalid={!!errors.confirmPassword} + aria-describedby={errors.confirmPassword ? 'confirmPassword-error' : undefined} + disabled={isSubmitting} + /> + {errors.confirmPassword && ( + + {errors.confirmPassword} + + )} +
+ + +
+
+
+ ); +} + +export default SetupPage; diff --git a/client/webpack.config.cjs b/client/webpack.config.cjs index fe6129fd5..99e7c5267 100644 --- a/client/webpack.config.cjs +++ b/client/webpack.config.cjs @@ -16,6 +16,10 @@ module.exports = (env, argv) => { }, resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'], + extensionAlias: { + '.js': ['.ts', '.tsx', '.js'], + '.jsx': ['.tsx', '.jsx'], + }, }, module: { rules: [ diff --git a/package-lock.json b/package-lock.json index 61cd2df8b..bc0230b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2994,6 +2994,15 @@ "node": ">=20.0.0" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -5131,6 +5140,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/argon2": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.43.0.tgz", + "integrity": "sha512-u/HKLcbWShVDhkfwI4hWyiUf3qyX8QhTfaIv2cWE18uqhXCmR5hb6Ed7oqYi2KCQegeAnRhiFzbjzm7i5yl1GA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.3.1", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -12649,6 +12673,15 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -12665,6 +12698,17 @@ "node": ">=18" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -20874,6 +20918,7 @@ "@cornerstone/shared": "*", "@fastify/compress": "8.0.2", "@fastify/static": "9.0.0", + "argon2": "0.43.0", "better-sqlite3": "12.6.2", "drizzle-orm": "0.45.1", "fastify": "5.7.4", diff --git a/server/package.json b/server/package.json index 72952de9e..ca796c263 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@cornerstone/shared": "*", "@fastify/compress": "8.0.2", "@fastify/static": "9.0.0", + "argon2": "0.43.0", "better-sqlite3": "12.6.2", "drizzle-orm": "0.45.1", "fastify": "5.7.4", diff --git a/server/src/app.ts b/server/src/app.ts index 41c6478f8..ebc3d1b0a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,6 +9,7 @@ import type { ApiErrorResponse } from '@cornerstone/shared'; import configPlugin from './plugins/config.js'; import dbPlugin from './plugins/db.js'; import errorHandlerPlugin from './plugins/errorHandler.js'; +import authRoutes from './routes/auth.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -31,6 +32,9 @@ export async function buildApp(): Promise { // Database connection & migrations await app.register(dbPlugin); + // Auth routes + await app.register(authRoutes, { prefix: '/api/auth' }); + // Health check endpoint app.get('/api/health', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; diff --git a/server/src/plugins/config.test.ts b/server/src/plugins/config.test.ts index 96d0d1193..e94d8aa97 100644 --- a/server/src/plugins/config.test.ts +++ b/server/src/plugins/config.test.ts @@ -17,6 +17,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { databaseUrl: '/app/data/cornerstone.db', logLevel: 'info', nodeEnv: 'production', + sessionDuration: 604800, + secureCookies: true, }); }); @@ -35,6 +37,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { databaseUrl: '/app/data/cornerstone.db', logLevel: 'info', nodeEnv: 'production', + sessionDuration: 604800, + secureCookies: true, }); }); }); @@ -55,6 +59,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { databaseUrl: '/custom/path/db.sqlite', logLevel: 'debug', nodeEnv: 'development', + sessionDuration: 604800, + secureCookies: true, }); }); @@ -70,6 +76,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { databaseUrl: '/app/data/cornerstone.db', logLevel: 'warn', nodeEnv: 'production', + sessionDuration: 604800, + secureCookies: true, }); }); }); diff --git a/server/src/plugins/config.ts b/server/src/plugins/config.ts index 6af39e494..979016b7f 100644 --- a/server/src/plugins/config.ts +++ b/server/src/plugins/config.ts @@ -7,6 +7,8 @@ export interface AppConfig { databaseUrl: string; logLevel: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; nodeEnv: string; + sessionDuration: number; // seconds + secureCookies: boolean; } // Type augmentation: makes fastify.config available across all routes/plugins @@ -61,6 +63,27 @@ export function loadConfig(env: Record): AppConfig { // NODE_ENV (simple string, no validation) const nodeEnv = getValue('NODE_ENV') ?? 'production'; + // Parse and validate SESSION_DURATION + const sessionDurationStr = getValue('SESSION_DURATION') ?? '604800'; + const sessionDuration = parseInt(sessionDurationStr, 10); + if (isNaN(sessionDuration)) { + errors.push(`SESSION_DURATION must be a valid number, got: ${sessionDurationStr}`); + } else if (sessionDuration <= 0) { + errors.push(`SESSION_DURATION must be greater than 0, got: ${sessionDuration}`); + } + + // Parse and validate SECURE_COOKIES + const secureCookiesStr = (getValue('SECURE_COOKIES') ?? 'true').toLowerCase(); + let secureCookies: boolean; + if (secureCookiesStr === 'true') { + secureCookies = true; + } else if (secureCookiesStr === 'false') { + secureCookies = false; + } else { + errors.push(`SECURE_COOKIES must be 'true' or 'false', got: ${getValue('SECURE_COOKIES')}`); + secureCookies = true; // Default fallback + } + // If there are any validation errors, throw a single error listing all of them if (errors.length > 0) { throw new Error(`Configuration validation failed:\n - ${errors.join('\n - ')}`); @@ -72,6 +95,8 @@ export function loadConfig(env: Record): AppConfig { databaseUrl, logLevel, nodeEnv, + sessionDuration, + secureCookies, }; } @@ -88,6 +113,8 @@ export default fp( databaseUrl: config.databaseUrl, logLevel: config.logLevel, nodeEnv: config.nodeEnv, + sessionDuration: config.sessionDuration, + secureCookies: config.secureCookies, }, 'Configuration loaded', ); diff --git a/server/src/routes/auth.test.ts b/server/src/routes/auth.test.ts new file mode 100644 index 000000000..fac158bb3 --- /dev/null +++ b/server/src/routes/auth.test.ts @@ -0,0 +1,733 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; +import { buildApp } from '../app.js'; +import * as userService from '../services/userService.js'; +import { users } from '../db/schema.js'; +import type { FastifyInstance } from 'fastify'; +import type { UserResponse } from '@cornerstone/shared'; + +describe('Authentication Routes', () => { + let app: FastifyInstance; + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + // Save original environment + originalEnv = { ...process.env }; + + // Create temporary directory for test database + tempDir = mkdtempSync(join(tmpdir(), 'cornerstone-auth-test-')); + process.env.DATABASE_URL = join(tempDir, 'test.db'); + + // Build app (runs migrations) + app = await buildApp(); + }); + + afterEach(async () => { + // Close the app + if (app) { + await app.close(); + } + + // Restore original environment + process.env = originalEnv; + + // Clean up temporary directory + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('GET /api/auth/me', () => { + it('returns setupRequired: true when no users exist', async () => { + // Given: Empty database (no users) + const userCount = userService.countUsers(app.db); + expect(userCount).toBe(0); + + // When: Getting current auth status + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + }); + + // Then: Response indicates setup is required + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toEqual({ + user: null, + setupRequired: true, + oidcEnabled: false, + }); + }); + + it('returns setupRequired: false when users exist', async () => { + // Given: One user exists in database + await userService.createLocalUser( + app.db, + 'existing@example.com', + 'Existing User', + 'password123456', + ); + + // When: Getting current auth status + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + }); + + // Then: Response indicates setup is complete + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toEqual({ + user: null, + setupRequired: false, + oidcEnabled: false, + }); + }); + + it('returns user: null (session support not yet implemented)', async () => { + // Given: User exists but no session support yet + await userService.createLocalUser(app.db, 'user@example.com', 'User', 'password123456'); + + // When: Getting current auth status + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + }); + + // Then: user is null (Story #32 will add session support) + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.user).toBeNull(); + }); + + it('always returns oidcEnabled: false (OIDC not yet implemented)', async () => { + // Given: App is running + // When: Getting current auth status + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + }); + + // Then: oidcEnabled is false + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.oidcEnabled).toBe(false); + }); + }); + + describe('POST /api/auth/setup', () => { + it('creates first admin user successfully (201)', async () => { + // Given: No users exist + expect(userService.countUsers(app.db)).toBe(0); + + // When: Creating first admin user via setup + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: 'SecurePassword123', + }, + }); + + // Then: Response is 201 Created + expect(response.statusCode).toBe(201); + + // And: Response contains user object + const body = JSON.parse(response.body) as { user: UserResponse }; + expect(body.user).toBeDefined(); + expect(body.user.email).toBe('admin@example.com'); + expect(body.user.displayName).toBe('Admin User'); + expect(body.user.role).toBe('admin'); + expect(body.user.authProvider).toBe('local'); + + // And: User is created in database + expect(userService.countUsers(app.db)).toBe(1); + }); + + it('returns 403 SETUP_COMPLETE when users already exist', async () => { + // Given: One user already exists + await userService.createLocalUser( + app.db, + 'existing@example.com', + 'Existing User', + 'password123456', + ); + + // When: Attempting setup again + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'second@example.com', + displayName: 'Second User', + password: 'AnotherPassword123', + }, + }); + + // Then: Response is 403 Forbidden + expect(response.statusCode).toBe(403); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('SETUP_COMPLETE'); + expect(body.error.message).toBe('Setup already complete'); + }); + + it('validates email format (400)', async () => { + // Given: Invalid email format + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'not-an-email', + displayName: 'Test User', + password: 'SecurePassword123', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates password minimum length 12 (400)', async () => { + // Given: Password too short + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: 'short', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates displayName required (400)', async () => { + // Given: Missing displayName + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + password: 'SecurePassword123', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates displayName not empty string (400)', async () => { + // Given: Empty displayName (violates minLength: 1) + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: '', + password: 'SecurePassword123', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates displayName maxLength 100 (400)', async () => { + // Given: displayName exceeds 100 characters + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'A'.repeat(101), + password: 'SecurePassword123', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates email required (400)', async () => { + // Given: Missing email + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + displayName: 'Admin User', + password: 'SecurePassword123', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates password required (400)', async () => { + // Given: Missing password + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('accepts payload even with additional properties (additionalProperties ignored)', async () => { + // Given: Payload with extra fields + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: 'SecurePassword123', + extraField: 'not-allowed', + }, + }); + + // Then: Request succeeds (extra fields are ignored, not rejected) + // Note: Fastify's default AJV configuration does not reject extra properties + expect(response.statusCode).toBe(201); + }); + + it('response never includes passwordHash', async () => { + // Given: Valid setup request + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: 'SecurePassword123', + }, + }); + + // Then: Response is successful + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body) as { user: UserResponse }; + + // And: passwordHash is not in response + expect(body.user).not.toHaveProperty('passwordHash'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((body.user as any).passwordHash).toBeUndefined(); + }); + + it('response never includes oidcSubject', async () => { + // Given: Valid setup request + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: 'SecurePassword123', + }, + }); + + // Then: Response is successful + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body) as { user: UserResponse }; + + // And: oidcSubject is not in response + expect(body.user).not.toHaveProperty('oidcSubject'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((body.user as any).oidcSubject).toBeUndefined(); + }); + + it('creates user with role admin (not member)', async () => { + // Given: Setup request + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: 'SecurePassword123', + }, + }); + + // Then: User has admin role + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body) as { user: UserResponse }; + expect(body.user.role).toBe('admin'); + }); + + it('accepts password exactly 12 characters (boundary)', async () => { + // Given: Password with exactly 12 characters + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: '123456789012', // Exactly 12 chars + }, + }); + + // Then: Request is accepted + expect(response.statusCode).toBe(201); + }); + + it('rejects password with 11 characters (below minimum)', async () => { + // Given: Password with 11 characters + const response = await app.inject({ + method: 'POST', + url: '/api/auth/setup', + payload: { + email: 'admin@example.com', + displayName: 'Admin User', + password: '12345678901', // 11 chars + }, + }); + + // Then: Request is rejected + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + }); + + describe('POST /api/auth/login', () => { + it('succeeds with valid credentials (200)', async () => { + // Given: User exists with known password + const email = 'login@example.com'; + const password = 'SecurePassword123'; + await userService.createLocalUser(app.db, email, 'Login User', password); + + // When: Logging in with correct credentials + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password }, + }); + + // Then: Response is 200 OK + expect(response.statusCode).toBe(200); + + // And: Response contains user object + const body = JSON.parse(response.body) as { user: UserResponse }; + expect(body.user).toBeDefined(); + expect(body.user.email).toBe(email); + expect(body.user.displayName).toBe('Login User'); + }); + + it('fails with wrong password (401 INVALID_CREDENTIALS)', async () => { + // Given: User exists + const email = 'user@example.com'; + await userService.createLocalUser(app.db, email, 'User', 'CorrectPassword123'); + + // When: Logging in with wrong password + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password: 'WrongPassword123' }, + }); + + // Then: Response is 401 Unauthorized + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('INVALID_CREDENTIALS'); + expect(body.error.message).toBe('Invalid email or password'); + }); + + it('fails with non-existent email (401 INVALID_CREDENTIALS)', async () => { + // Given: User does not exist + // When: Logging in with non-existent email + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'nonexistent@example.com', password: 'AnyPassword123' }, + }); + + // Then: Response is 401 Unauthorized + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('INVALID_CREDENTIALS'); + expect(body.error.message).toBe('Invalid email or password'); + }); + + it('fails for deactivated user (401 ACCOUNT_DEACTIVATED)', async () => { + // Given: User exists but is deactivated + const email = 'deactivated@example.com'; + const password = 'SecurePassword123'; + const user = await userService.createLocalUser(app.db, email, 'Deactivated User', password); + + // Deactivate user + app.db + .update(users) + .set({ deactivatedAt: new Date().toISOString() }) + .where(eq(users.id, user.id)) + .run(); + + // When: Attempting to log in + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password }, + }); + + // Then: Response is 401 Unauthorized + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('ACCOUNT_DEACTIVATED'); + expect(body.error.message).toBe('Account has been deactivated'); + }); + + it('validates body schema - missing email (400)', async () => { + // Given: Request missing email + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: 'SecurePassword123' }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates body schema - missing password (400)', async () => { + // Given: Request missing password + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'user@example.com' }, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates body schema - empty object (400)', async () => { + // Given: Empty payload + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: {}, + }); + + // Then: Response is 400 Bad Request + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('accepts payload even with additional properties (additionalProperties ignored)', async () => { + // Given: User exists + await userService.createLocalUser(app.db, 'user@example.com', 'User', 'SecurePassword123'); + + // When: Payload with extra fields + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'user@example.com', + password: 'SecurePassword123', + extraField: 'not-allowed', + }, + }); + + // Then: Request succeeds (extra fields are ignored, not rejected) + // Note: Fastify's default AJV configuration does not reject extra properties + expect(response.statusCode).toBe(200); + }); + + it('response never includes passwordHash', async () => { + // Given: User exists + const email = 'user@example.com'; + const password = 'SecurePassword123'; + await userService.createLocalUser(app.db, email, 'User', password); + + // When: Logging in successfully + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password }, + }); + + // Then: Response is successful + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as { user: UserResponse }; + + // And: passwordHash is not in response + expect(body.user).not.toHaveProperty('passwordHash'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((body.user as any).passwordHash).toBeUndefined(); + }); + + it('response never includes oidcSubject', async () => { + // Given: User exists + const email = 'user@example.com'; + const password = 'SecurePassword123'; + await userService.createLocalUser(app.db, email, 'User', password); + + // When: Logging in successfully + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password }, + }); + + // Then: Response is successful + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as { user: UserResponse }; + + // And: oidcSubject is not in response + expect(body.user).not.toHaveProperty('oidcSubject'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((body.user as any).oidcSubject).toBeUndefined(); + }); + + it('performs timing-attack prevention (hashes dummy password when user not found)', async () => { + // Given: No user exists with this email + // When: Attempting to log in + const startTime = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'nonexistent@example.com', password: 'SomePassword123' }, + }); + const elapsed = Date.now() - startTime; + + // Then: Response is 401 + expect(response.statusCode).toBe(401); + + // And: Response took reasonable time (argon2 hash takes >50ms typically) + // This is a weak assertion but verifies the dummy hash is executed + expect(elapsed).toBeGreaterThan(10); // At least some processing time + }); + + it('timing-attack prevention applies when user has no passwordHash (OIDC user)', async () => { + // Given: OIDC user (no passwordHash) + const now = new Date().toISOString(); + app.db + .insert(users) + .values({ + id: 'oidc-user-123', + email: 'oidc@example.com', + displayName: 'OIDC User', + role: 'member', + authProvider: 'oidc', + passwordHash: null, + oidcSubject: 'oidc-subject-123', + deactivatedAt: null, + createdAt: now, + updatedAt: now, + }) + .run(); + + // When: Attempting to log in with password + const startTime = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'oidc@example.com', password: 'SomePassword123' }, + }); + const elapsed = Date.now() - startTime; + + // Then: Response is 401 + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('INVALID_CREDENTIALS'); + + // And: Took reasonable time (dummy hash executed) + expect(elapsed).toBeGreaterThan(10); + }); + + it('allows login for active user (deactivatedAt is null)', async () => { + // Given: Active user + const email = 'active@example.com'; + const password = 'SecurePassword123'; + const user = await userService.createLocalUser(app.db, email, 'Active User', password); + expect(user.deactivatedAt).toBeNull(); + + // When: Logging in + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password }, + }); + + // Then: Login succeeds + expect(response.statusCode).toBe(200); + }); + + it('login is case-sensitive for email', async () => { + // Given: User with lowercase email + const email = 'user@example.com'; + const password = 'SecurePassword123'; + await userService.createLocalUser(app.db, email, 'User', password); + + // When: Attempting login with uppercase email + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'USER@EXAMPLE.COM', password }, + }); + + // Then: Login fails (case-sensitive) + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error.code).toBe('INVALID_CREDENTIALS'); + }); + + it('returns user with all safe fields populated', async () => { + // Given: User exists + const email = 'complete@example.com'; + const password = 'SecurePassword123'; + await userService.createLocalUser(app.db, email, 'Complete User', password, 'admin'); + + // When: Logging in + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email, password }, + }); + + // Then: Response includes all safe user fields + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as { user: UserResponse }; + expect(body.user.id).toBeDefined(); + expect(body.user.email).toBe(email); + expect(body.user.displayName).toBe('Complete User'); + expect(body.user.role).toBe('admin'); + expect(body.user.authProvider).toBe('local'); + expect(body.user.createdAt).toBeDefined(); + expect(body.user.updatedAt).toBeDefined(); + expect(body.user.deactivatedAt).toBeNull(); + }); + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 000000000..490702ec3 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,135 @@ +import type { FastifyInstance } from 'fastify'; +import { AppError } from '../errors/AppError.js'; +import * as userService from '../services/userService.js'; + +// JSON schema for request validation (Fastify/AJV) +const setupSchema = { + body: { + type: 'object', + required: ['email', 'displayName', 'password'], + properties: { + email: { type: 'string', format: 'email' }, + displayName: { type: 'string', minLength: 1, maxLength: 100 }, + password: { type: 'string', minLength: 12 }, + }, + additionalProperties: false, + }, +}; + +const loginSchema = { + body: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string' }, + password: { type: 'string' }, + }, + additionalProperties: false, + }, +}; + +export default async function authRoutes(fastify: FastifyInstance) { + /** + * GET /api/auth/me + * + * Returns the current authenticated user, or null if not authenticated. + * Also indicates whether initial setup is required. + */ + fastify.get('/me', async (request, reply) => { + const userCount = userService.countUsers(fastify.db); + + if (userCount === 0) { + // Setup required + return reply.status(200).send({ + user: null, + setupRequired: true, + oidcEnabled: false, + }); + } + + // Users exist but no session yet (session support added in Story #32) + return reply.status(200).send({ + user: null, + setupRequired: false, + oidcEnabled: false, + }); + }); + + /** + * POST /api/auth/setup + * + * Creates the first admin user. Only works when no users exist. + * After setup is complete, returns 403 SETUP_COMPLETE. + */ + fastify.post('/setup', { schema: setupSchema }, async (request, reply) => { + const { email, displayName, password } = request.body as { + email: string; + displayName: string; + password: string; + }; + + // Check if setup is already complete + const userCount = userService.countUsers(fastify.db); + if (userCount > 0) { + throw new AppError('SETUP_COMPLETE', 403, 'Setup already complete'); + } + + // Create the first admin user + const user = await userService.createLocalUser( + fastify.db, + email, + displayName, + password, + 'admin', + ); + + // NOTE: Session creation will be added in Story #32 + return reply.status(201).send({ + user: userService.toUserResponse(user), + }); + }); + + /** + * POST /api/auth/login + * + * Authenticates a local user with email and password. + * Returns the user object on success. + * NOTE: Session creation will be added in Story #32. + */ + fastify.post('/login', { schema: loginSchema }, async (request, reply) => { + const { email, password } = request.body as { + email: string; + password: string; + }; + + // Find user by email + const user = userService.findByEmail(fastify.db, email); + + // If no user found OR user is OIDC (no password_hash), still hash a dummy password + // (timing attack prevention) + if (!user || !user.passwordHash) { + // Hash a dummy password to prevent timing attacks + await userService.verifyPassword( + '$argon2id$v=19$m=65536,t=3,p=4$aGVsbG93b3JsZA$cZn5d+rFz8E4HMhH+3e6Ug', + password, + ); + throw new AppError('INVALID_CREDENTIALS', 401, 'Invalid email or password'); + } + + // Check if user is deactivated + if (user.deactivatedAt) { + throw new AppError('ACCOUNT_DEACTIVATED', 401, 'Account has been deactivated'); + } + + // Verify password + const passwordValid = await userService.verifyPassword(user.passwordHash, password); + if (!passwordValid) { + throw new AppError('INVALID_CREDENTIALS', 401, 'Invalid email or password'); + } + + // NOTE: Session creation will be added in Story #32 + return reply.status(200).send({ + user: userService.toUserResponse(user), + }); + }); +} diff --git a/server/src/services/userService.test.ts b/server/src/services/userService.test.ts new file mode 100644 index 000000000..f643d8f43 --- /dev/null +++ b/server/src/services/userService.test.ts @@ -0,0 +1,633 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import { runMigrations } from '../db/migrate.js'; +import * as schema from '../db/schema.js'; +import * as userService from './userService.js'; + +describe('User Service', () => { + let sqlite: Database.Database; + let db: BetterSQLite3Database; + + /** + * Creates a fresh in-memory database with migrations applied. + */ + function createTestDb() { + const sqliteDb = new Database(':memory:'); + sqliteDb.pragma('journal_mode = WAL'); + sqliteDb.pragma('foreign_keys = ON'); + runMigrations(sqliteDb); + return { sqlite: sqliteDb, db: drizzle(sqliteDb, { schema }) }; + } + + beforeEach(() => { + const testDb = createTestDb(); + sqlite = testDb.sqlite; + db = testDb.db; + }); + + afterEach(() => { + sqlite.close(); + }); + + describe('toUserResponse()', () => { + it('converts DB row to UserResponse (strips passwordHash and oidcSubject)', () => { + // Given: A database user row with all fields including sensitive ones + const dbRow: typeof schema.users.$inferSelect = { + id: 'user-123', + email: 'test@example.com', + displayName: 'Test User', + role: 'admin', + authProvider: 'local', + passwordHash: '$argon2id$v=19$m=65536,t=3,p=4$hashedpassword', + oidcSubject: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deactivatedAt: null, + }; + + // When: Converting to UserResponse + const response = userService.toUserResponse(dbRow); + + // Then: Response contains all safe fields + expect(response).toEqual({ + id: 'user-123', + email: 'test@example.com', + displayName: 'Test User', + role: 'admin', + authProvider: 'local', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deactivatedAt: null, + }); + + // And: passwordHash is not included + expect(response).not.toHaveProperty('passwordHash'); + + // And: oidcSubject is not included + expect(response).not.toHaveProperty('oidcSubject'); + }); + + it('strips passwordHash from local auth user', () => { + // Given: A local auth user with password hash + const dbRow: typeof schema.users.$inferSelect = { + id: 'local-user', + email: 'local@example.com', + displayName: 'Local User', + role: 'member', + authProvider: 'local', + passwordHash: '$argon2id$v=19$m=65536,t=3,p=4$somehash', + oidcSubject: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deactivatedAt: null, + }; + + // When: Converting to response + const response = userService.toUserResponse(dbRow); + + // Then: passwordHash is not in response + expect(response).not.toHaveProperty('passwordHash'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((response as any).passwordHash).toBeUndefined(); + }); + + it('strips oidcSubject from OIDC auth user', () => { + // Given: An OIDC auth user with oidcSubject + const dbRow: typeof schema.users.$inferSelect = { + id: 'oidc-user', + email: 'oidc@example.com', + displayName: 'OIDC User', + role: 'member', + authProvider: 'oidc', + passwordHash: null, + oidcSubject: 'oidc-provider-subject-123', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deactivatedAt: null, + }; + + // When: Converting to response + const response = userService.toUserResponse(dbRow); + + // Then: oidcSubject is not in response + expect(response).not.toHaveProperty('oidcSubject'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((response as any).oidcSubject).toBeUndefined(); + }); + + it('includes deactivatedAt when user is deactivated', () => { + // Given: A deactivated user + const dbRow: typeof schema.users.$inferSelect = { + id: 'deactivated-user', + email: 'deactivated@example.com', + displayName: 'Deactivated User', + role: 'member', + authProvider: 'local', + passwordHash: '$argon2id$v=19$m=65536,t=3,p=4$hash', + oidcSubject: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deactivatedAt: '2024-06-01T10:00:00.000Z', + }; + + // When: Converting to response + const response = userService.toUserResponse(dbRow); + + // Then: deactivatedAt is included + expect(response.deactivatedAt).toBe('2024-06-01T10:00:00.000Z'); + }); + }); + + describe('createLocalUser()', () => { + it('creates user with hashed password (not plain text)', async () => { + // Given: User details with plain text password + const email = 'newuser@example.com'; + const displayName = 'New User'; + const password = 'MySecurePassword123'; + + // When: Creating local user + const user = await userService.createLocalUser(db, email, displayName, password); + + // Then: User is created + expect(user).toBeDefined(); + expect(user.email).toBe(email); + expect(user.displayName).toBe(displayName); + expect(user.authProvider).toBe('local'); + + // And: passwordHash is set and NOT equal to plain text password + expect(user.passwordHash).toBeDefined(); + expect(user.passwordHash).not.toBe(password); + + // And: passwordHash is argon2 format + expect(user.passwordHash).toMatch(/^\$argon2id\$/); + }); + + it('defaults role to member when not specified', async () => { + // Given: User details without role parameter + const user = await userService.createLocalUser( + db, + 'member@example.com', + 'Member User', + 'password123456', + ); + + // Then: Role defaults to member + expect(user.role).toBe('member'); + }); + + it('creates user with explicit admin role', async () => { + // Given: User details with admin role + const user = await userService.createLocalUser( + db, + 'admin@example.com', + 'Admin User', + 'adminpassword123', + 'admin', + ); + + // Then: Role is admin + expect(user.role).toBe('admin'); + }); + + it('creates user with explicit member role', async () => { + // Given: User details with explicit member role + const user = await userService.createLocalUser( + db, + 'member2@example.com', + 'Member Two', + 'memberpassword123', + 'member', + ); + + // Then: Role is member + expect(user.role).toBe('member'); + }); + + it('sets authProvider to local', async () => { + // Given: User creation request + const user = await userService.createLocalUser( + db, + 'local@example.com', + 'Local', + 'password123456', + ); + + // Then: authProvider is local + expect(user.authProvider).toBe('local'); + }); + + it('sets oidcSubject to null for local users', async () => { + // Given: Local user creation + const user = await userService.createLocalUser( + db, + 'local2@example.com', + 'Local Two', + 'password123456', + ); + + // Then: oidcSubject is null + expect(user.oidcSubject).toBeNull(); + }); + + it('generates unique UUID for user ID', async () => { + // Given: Two users created + const user1 = await userService.createLocalUser( + db, + 'user1@example.com', + 'User One', + 'password123456', + ); + const user2 = await userService.createLocalUser( + db, + 'user2@example.com', + 'User Two', + 'password123456', + ); + + // Then: IDs are different UUIDs + expect(user1.id).not.toBe(user2.id); + expect(user1.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(user2.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + }); + + it('sets createdAt and updatedAt timestamps', async () => { + // Given: User creation + const user = await userService.createLocalUser( + db, + 'timestamped@example.com', + 'Timestamped User', + 'password123456', + ); + + // Then: Timestamps are set in ISO format + expect(user.createdAt).toBeDefined(); + expect(user.updatedAt).toBeDefined(); + expect(user.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(user.updatedAt).toBe(user.createdAt); + }); + + it('sets deactivatedAt to null by default', async () => { + // Given: User creation + const user = await userService.createLocalUser( + db, + 'active@example.com', + 'Active User', + 'password123456', + ); + + // Then: deactivatedAt is null + expect(user.deactivatedAt).toBeNull(); + }); + + it('returns the complete user row from database', async () => { + // Given: User creation + const user = await userService.createLocalUser( + db, + 'complete@example.com', + 'Complete User', + 'password123456', + 'admin', + ); + + // Then: Returned object has all database columns + expect(user).toHaveProperty('id'); + expect(user).toHaveProperty('email'); + expect(user).toHaveProperty('displayName'); + expect(user).toHaveProperty('role'); + expect(user).toHaveProperty('authProvider'); + expect(user).toHaveProperty('passwordHash'); + expect(user).toHaveProperty('oidcSubject'); + expect(user).toHaveProperty('createdAt'); + expect(user).toHaveProperty('updatedAt'); + expect(user).toHaveProperty('deactivatedAt'); + }); + }); + + describe('verifyPassword()', () => { + it('returns true for matching password', async () => { + // Given: User with known password + const password = 'MySecurePassword123'; + const user = await userService.createLocalUser( + db, + 'verify@example.com', + 'Verify User', + password, + ); + + // When: Verifying with correct password + const isValid = await userService.verifyPassword(user.passwordHash!, password); + + // Then: Verification succeeds + expect(isValid).toBe(true); + }); + + it('returns false for wrong password', async () => { + // Given: User with known password + const password = 'MySecurePassword123'; + const user = await userService.createLocalUser( + db, + 'verify2@example.com', + 'Verify User Two', + password, + ); + + // When: Verifying with incorrect password + const isValid = await userService.verifyPassword(user.passwordHash!, 'WrongPassword456'); + + // Then: Verification fails + expect(isValid).toBe(false); + }); + + it('returns false for empty password against valid hash', async () => { + // Given: User with password + const user = await userService.createLocalUser( + db, + 'verify3@example.com', + 'Verify User Three', + 'MySecurePassword123', + ); + + // When: Verifying with empty string + const isValid = await userService.verifyPassword(user.passwordHash!, ''); + + // Then: Verification fails + expect(isValid).toBe(false); + }); + + it('handles different passwords for same user', async () => { + // Given: User with password + const correctPassword = 'CorrectPassword123'; + const user = await userService.createLocalUser( + db, + 'verify4@example.com', + 'Verify User Four', + correctPassword, + ); + + // When: Testing multiple wrong passwords + const wrongPasswords = [ + 'WrongPassword1', + 'DifferentPassword2', + 'AnotherWrong3', + 'correctpassword123', // Wrong case + 'CorrectPassword12', // Missing character + 'CorrectPassword1234', // Extra character + ]; + + // Then: All wrong passwords fail + for (const wrong of wrongPasswords) { + const isValid = await userService.verifyPassword(user.passwordHash!, wrong); + expect(isValid).toBe(false); + } + + // And: Correct password succeeds + const correctIsValid = await userService.verifyPassword(user.passwordHash!, correctPassword); + expect(correctIsValid).toBe(true); + }); + }); + + describe('findByEmail()', () => { + it('returns user when email exists', async () => { + // Given: User in database + const email = 'find@example.com'; + const createdUser = await userService.createLocalUser( + db, + email, + 'Find User', + 'password123456', + ); + + // When: Finding by email + const foundUser = userService.findByEmail(db, email); + + // Then: User is found + expect(foundUser).toBeDefined(); + expect(foundUser?.id).toBe(createdUser.id); + expect(foundUser?.email).toBe(email); + }); + + it('returns undefined when email does not exist', () => { + // Given: Empty database (no users created) + // When: Finding by non-existent email + const foundUser = userService.findByEmail(db, 'nonexistent@example.com'); + + // Then: No user is found + expect(foundUser).toBeUndefined(); + }); + + it('email lookup is case-sensitive', async () => { + // Given: User with lowercase email + await userService.createLocalUser( + db, + 'lowercase@example.com', + 'Lowercase User', + 'password123456', + ); + + // When: Finding by uppercase email + const foundUser = userService.findByEmail(db, 'LOWERCASE@EXAMPLE.COM'); + + // Then: User is NOT found (case-sensitive) + expect(foundUser).toBeUndefined(); + }); + + it('returns complete user row including passwordHash', async () => { + // Given: User in database + const user = await userService.createLocalUser( + db, + 'complete2@example.com', + 'Complete User Two', + 'password123456', + ); + + // When: Finding by email + const foundUser = userService.findByEmail(db, 'complete2@example.com'); + + // Then: Complete row is returned + expect(foundUser).toBeDefined(); + expect(foundUser?.passwordHash).toBeDefined(); + expect(foundUser?.passwordHash).toBe(user.passwordHash); + }); + + it('can find deactivated users', async () => { + // Given: User created and then deactivated (via direct DB update) + const email = 'deactivated2@example.com'; + const user = await userService.createLocalUser( + db, + email, + 'Deactivated Two', + 'password123456', + ); + + // Deactivate user + db.update(schema.users) + .set({ deactivatedAt: new Date().toISOString() }) + .where(eq(schema.users.id, user.id)) + .run(); + + // When: Finding by email + const foundUser = userService.findByEmail(db, email); + + // Then: User is still found + expect(foundUser).toBeDefined(); + expect(foundUser?.deactivatedAt).not.toBeNull(); + }); + }); + + describe('countUsers()', () => { + it('returns 0 for empty database', () => { + // Given: Empty database (no users created) + // When: Counting users + const count = userService.countUsers(db); + + // Then: Count is 0 + expect(count).toBe(0); + }); + + it('returns correct count after inserting users', async () => { + // Given: Three users in database + await userService.createLocalUser(db, 'user1@example.com', 'User One', 'password123456'); + await userService.createLocalUser(db, 'user2@example.com', 'User Two', 'password123456'); + await userService.createLocalUser(db, 'user3@example.com', 'User Three', 'password123456'); + + // When: Counting users + const count = userService.countUsers(db); + + // Then: Count is 3 + expect(count).toBe(3); + }); + + it('includes deactivated users in count', async () => { + // Given: Two active users and one deactivated user + await userService.createLocalUser(db, 'active1@example.com', 'Active One', 'password123456'); + await userService.createLocalUser(db, 'active2@example.com', 'Active Two', 'password123456'); + + const deactivatedUser = await userService.createLocalUser( + db, + 'deactivated3@example.com', + 'Deactivated Three', + 'password123456', + ); + + // Deactivate one user + db.update(schema.users) + .set({ deactivatedAt: new Date().toISOString() }) + .where(eq(schema.users.id, deactivatedUser.id)) + .run(); + + // When: Counting all users + const count = userService.countUsers(db); + + // Then: Count includes deactivated user (3 total) + expect(count).toBe(3); + }); + + it('increments count after each insert', async () => { + // Given: Starting count + expect(userService.countUsers(db)).toBe(0); + + // When: Inserting users one by one + await userService.createLocalUser(db, 'user1@example.com', 'User One', 'password123456'); + expect(userService.countUsers(db)).toBe(1); + + await userService.createLocalUser(db, 'user2@example.com', 'User Two', 'password123456'); + expect(userService.countUsers(db)).toBe(2); + + await userService.createLocalUser(db, 'user3@example.com', 'User Three', 'password123456'); + expect(userService.countUsers(db)).toBe(3); + }); + }); + + describe('countActiveUsers()', () => { + it('returns 0 for empty database', () => { + // Given: Empty database + // When: Counting active users + const count = userService.countActiveUsers(db); + + // Then: Count is 0 + expect(count).toBe(0); + }); + + it('returns correct count of active users (excludes deactivated)', async () => { + // Given: Three active users + await userService.createLocalUser(db, 'active1@example.com', 'Active One', 'password123456'); + await userService.createLocalUser(db, 'active2@example.com', 'Active Two', 'password123456'); + await userService.createLocalUser( + db, + 'active3@example.com', + 'Active Three', + 'password123456', + ); + + // When: Counting active users + const count = userService.countActiveUsers(db); + + // Then: Count is 3 + expect(count).toBe(3); + }); + + it('excludes deactivated users from count', async () => { + // Given: Two active users and two deactivated users + await userService.createLocalUser(db, 'active1@example.com', 'Active One', 'password123456'); + await userService.createLocalUser(db, 'active2@example.com', 'Active Two', 'password123456'); + + const deactivatedUser1 = await userService.createLocalUser( + db, + 'deactivated1@example.com', + 'Deactivated One', + 'password123456', + ); + const deactivatedUser2 = await userService.createLocalUser( + db, + 'deactivated2@example.com', + 'Deactivated Two', + 'password123456', + ); + + // Deactivate two users + db.update(schema.users) + .set({ deactivatedAt: new Date().toISOString() }) + .where(eq(schema.users.id, deactivatedUser1.id)) + .run(); + db.update(schema.users) + .set({ deactivatedAt: new Date().toISOString() }) + .where(eq(schema.users.id, deactivatedUser2.id)) + .run(); + + // When: Counting active users + const count = userService.countActiveUsers(db); + + // Then: Count is 2 (excludes deactivated) + expect(count).toBe(2); + }); + + it('counts only users where deactivatedAt IS NULL', async () => { + // Given: Mix of active and deactivated users + await userService.createLocalUser(db, 'active@example.com', 'Active', 'password123456'); + + const deactivatedUser = await userService.createLocalUser( + db, + 'deactivated@example.com', + 'Deactivated', + 'password123456', + ); + + // Deactivate one user + db.update(schema.users) + .set({ deactivatedAt: '2024-06-01T10:00:00.000Z' }) + .where(eq(schema.users.id, deactivatedUser.id)) + .run(); + + // When: Counting active users + const activeCount = userService.countActiveUsers(db); + const totalCount = userService.countUsers(db); + + // Then: Active count excludes deactivated user + expect(activeCount).toBe(1); + expect(totalCount).toBe(2); + }); + }); +}); diff --git a/server/src/services/userService.ts b/server/src/services/userService.ts new file mode 100644 index 000000000..011bf2a9d --- /dev/null +++ b/server/src/services/userService.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'node:crypto'; +import argon2 from 'argon2'; +import { eq, isNull, sql } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schemaTypes from '../db/schema.js'; +import { users } from '../db/schema.js'; +import type { UserResponse } from '@cornerstone/shared'; + +type DbType = BetterSQLite3Database; + +/** + * Convert DB row to UserResponse (never includes password_hash or oidc_subject). + * + * @param row - Database user row + * @returns UserResponse object safe for API responses + */ +export function toUserResponse(row: typeof users.$inferSelect): UserResponse { + return { + id: row.id, + email: row.email, + displayName: row.displayName, + role: row.role, + authProvider: row.authProvider, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deactivatedAt: row.deactivatedAt, + }; +} + +/** + * Create a new local authentication user. + * + * @param db - Database instance + * @param email - User email address + * @param displayName - User display name + * @param password - Plain text password (will be hashed) + * @param role - User role (admin or member) + * @returns The created user row + */ +export async function createLocalUser( + db: DbType, + email: string, + displayName: string, + password: string, + role: 'admin' | 'member' = 'member', +): Promise { + const now = new Date().toISOString(); + const passwordHash = await argon2.hash(password); + const id = randomUUID(); + + db.insert(users) + .values({ + id, + email, + displayName, + role, + authProvider: 'local', + passwordHash, + createdAt: now, + updatedAt: now, + }) + .run(); + + // Return the inserted row + const row = db.select().from(users).where(eq(users.id, id)).get(); + return row!; +} + +/** + * Verify a password against an argon2 hash. + * + * @param hash - The argon2 password hash + * @param password - The plain text password to verify + * @returns True if password matches, false otherwise + */ +export async function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password); +} + +/** + * Find a user by email address. + * + * @param db - Database instance + * @param email - User email to search for + * @returns User row or undefined if not found + */ +export function findByEmail(db: DbType, email: string): typeof users.$inferSelect | undefined { + return db.select().from(users).where(eq(users.email, email)).get(); +} + +/** + * Count all users in the system. + * + * @param db - Database instance + * @returns Total user count + */ +export function countUsers(db: DbType): number { + const result = db + .select({ count: sql`COUNT(*)` }) + .from(users) + .get(); + return result?.count ?? 0; +} + +/** + * Count all active (non-deactivated) users in the system. + * + * @param db - Database instance + * @returns Active user count + */ +export function countActiveUsers(db: DbType): number { + const result = db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(isNull(users.deactivatedAt)) + .get(); + return result?.count ?? 0; +}