# NaviDocs Auth System - Implementation Task Breakdown ## Overview This document breaks down the multi-tenancy authentication and authorization system into granular, PR-sized tasks with clear acceptance criteria, module boundaries, and testing requirements. --- ## Phase 1: Foundation ### Task 1.1: Database Schema Migration **Branch:** `feat/auth-schema-migration` **Estimated Effort:** 4 hours **Priority:** P0 (Blocker) **Deliverables:** 1. Migration script: `/migrations/003_auth_tables.sql` 2. Rollback script: `/migrations/003_auth_tables_down.sql` 3. Migration runner updates **Schema Changes:** ```sql -- /migrations/003_auth_tables.sql -- Entity-level permissions (granular access control) CREATE TABLE entity_permissions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, entity_id TEXT NOT NULL, permission_level TEXT NOT NULL CHECK(permission_level IN ('viewer', 'editor', 'manager', 'admin')), granted_by TEXT NOT NULL, granted_at INTEGER NOT NULL, expires_at INTEGER, UNIQUE(user_id, entity_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX idx_entity_perms_user ON entity_permissions(user_id); CREATE INDEX idx_entity_perms_entity ON entity_permissions(entity_id); CREATE INDEX idx_entity_perms_expires ON entity_permissions(expires_at); -- Refresh tokens for secure session management CREATE TABLE refresh_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, device_info TEXT, ip_address TEXT, expires_at INTEGER NOT NULL, revoked BOOLEAN DEFAULT 0, revoked_at INTEGER, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at); CREATE INDEX idx_refresh_tokens_revoked ON refresh_tokens(revoked); -- Password reset tokens CREATE TABLE password_reset_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at INTEGER NOT NULL, used BOOLEAN DEFAULT 0, used_at INTEGER, ip_address TEXT, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_reset_tokens_user ON password_reset_tokens(user_id); CREATE INDEX idx_reset_tokens_expires ON password_reset_tokens(expires_at); -- Audit log for security events CREATE TABLE audit_log ( id TEXT PRIMARY KEY, user_id TEXT, event_type TEXT NOT NULL, resource_type TEXT, resource_id TEXT, ip_address TEXT, user_agent TEXT, status TEXT NOT NULL CHECK(status IN ('success', 'failure', 'denied')), metadata TEXT, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX idx_audit_user ON audit_log(user_id); CREATE INDEX idx_audit_event ON audit_log(event_type); CREATE INDEX idx_audit_created ON audit_log(created_at); CREATE INDEX idx_audit_status ON audit_log(status); -- Add email verification to users table ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0; ALTER TABLE users ADD COLUMN email_verification_token TEXT; ALTER TABLE users ADD COLUMN email_verification_expires INTEGER; -- Add account status to users ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'deleted')); ALTER TABLE users ADD COLUMN suspended_at INTEGER; ALTER TABLE users ADD COLUMN suspended_reason TEXT; -- Add role constraint to user_organizations -- SQLite doesn't support ALTER CHECK, so we document it here -- In production PostgreSQL, add: CHECK(role IN ('admin', 'manager', 'member', 'viewer')) ``` **Acceptance Criteria:** - [ ] Migration script runs without errors on clean database - [ ] Migration script is idempotent (can run multiple times safely) - [ ] Rollback script cleanly reverts all changes - [ ] All foreign keys are enforced (test with invalid data) - [ ] All indexes improve query performance (verify with EXPLAIN QUERY PLAN) - [ ] CHECK constraints prevent invalid data - [ ] Migration runner logs progress and errors **Testing:** ```javascript // /test/migrations/003_auth_tables.test.js describe('Auth Tables Migration', () => { it('creates all tables', () => { // Verify tables exist }); it('creates all indexes', () => { // Verify indexes exist }); it('enforces foreign key constraints', () => { // Try to insert invalid foreign key }); it('enforces check constraints', () => { // Try to insert invalid permission_level }); it('rollback removes all tables', () => { // Run down migration and verify cleanup }); }); ``` **Files Changed:** - `/migrations/003_auth_tables.sql` (new) - `/migrations/003_auth_tables_down.sql` (new) - `/run-migration.js` (update to run new migration) - `/test/migrations/003_auth_tables.test.js` (new) --- ### Task 1.2: Authentication Service Core **Branch:** `feat/auth-service` **Estimated Effort:** 8 hours **Priority:** P0 (Blocker) **Dependencies:** Task 1.1 **Module Interface:** ```javascript // /services/auth.js import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../db/db.js'; import { AuditService } from './audit.js'; const BCRYPT_ROUNDS = 12; const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; const REFRESH_TOKEN_EXPIRES_IN = 7 * 24 * 60 * 60 * 1000; // 7 days export class AuthService { /** * Register a new user * @param {string} email - User email (unique) * @param {string} password - Plain text password (min 8 chars) * @param {string} name - User display name * @returns {Promise<{id: string, email: string, name: string}>} * @throws {Error} If email already exists or validation fails */ async register(email, password, name) { // Validate inputs this._validateEmail(email); this._validatePassword(password); const db = getDb(); // Check if email exists const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); if (existing) { throw new Error('Email already registered'); } // Hash password const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); // Generate email verification token const verificationToken = uuidv4(); const verificationExpires = Date.now() + (24 * 60 * 60 * 1000); // 24 hours // Create user const userId = uuidv4(); const now = Date.now(); db.prepare(` INSERT INTO users ( id, email, name, password_hash, status, email_verified, email_verification_token, email_verification_expires, created_at, updated_at ) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?) `).run(userId, email, name, passwordHash, verificationToken, verificationExpires, now, now); // Create personal organization const orgId = uuidv4(); db.prepare(` INSERT INTO organizations (id, name, type, created_at, updated_at) VALUES (?, ?, 'personal', ?, ?) `).run(orgId, `${name}'s Organization`, now, now); // Add user to organization as admin db.prepare(` INSERT INTO user_organizations (user_id, organization_id, role, joined_at) VALUES (?, ?, 'admin', ?) `).run(userId, orgId, now); // Log audit event await AuditService.logEvent('user_registered', userId, null, null, 'success', { email, organization_id: orgId }); return { id: userId, email, name, emailVerified: false, verificationToken // Return for testing; in production, send via email }; } /** * Authenticate user and generate tokens * @param {string} email * @param {string} password * @param {Object} deviceInfo - { userAgent, ipAddress } * @returns {Promise<{accessToken: string, refreshToken: string, user: Object}>} * @throws {Error} If credentials invalid or account suspended */ async login(email, password, deviceInfo = {}) { const db = getDb(); // Get user const user = db.prepare(` SELECT id, email, name, password_hash, status, email_verified FROM users WHERE email = ? `).get(email); if (!user) { await AuditService.logEvent('login_failed', null, null, null, 'failure', { email, reason: 'user_not_found', ip_address: deviceInfo.ipAddress }); throw new Error('Invalid credentials'); } // Verify password const passwordValid = await bcrypt.compare(password, user.password_hash); if (!passwordValid) { await AuditService.logEvent('login_failed', user.id, null, null, 'failure', { email, reason: 'invalid_password', ip_address: deviceInfo.ipAddress }); throw new Error('Invalid credentials'); } // Check account status if (user.status === 'suspended') { await AuditService.logEvent('login_denied', user.id, null, null, 'denied', { reason: 'account_suspended', ip_address: deviceInfo.ipAddress }); throw new Error('Account suspended'); } if (user.status === 'deleted') { throw new Error('Account not found'); } // Generate access token (JWT) const accessToken = jwt.sign( { sub: user.id, email: user.email, name: user.name, emailVerified: user.email_verified === 1 }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: 'navidocs-api' } ); // Generate refresh token const refreshToken = uuidv4(); const refreshTokenHash = await bcrypt.hash(refreshToken, BCRYPT_ROUNDS); const refreshTokenId = uuidv4(); const expiresAt = Date.now() + REFRESH_TOKEN_EXPIRES_IN; db.prepare(` INSERT INTO refresh_tokens ( id, user_id, token_hash, device_info, ip_address, expires_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( refreshTokenId, user.id, refreshTokenHash, JSON.stringify(deviceInfo), deviceInfo.ipAddress, expiresAt, Date.now() ); // Update last login db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?') .run(Date.now(), user.id); // Log successful login await AuditService.logEvent('login_success', user.id, null, null, 'success', { ip_address: deviceInfo.ipAddress, user_agent: deviceInfo.userAgent }); return { accessToken, refreshToken, user: { id: user.id, email: user.email, name: user.name, emailVerified: user.email_verified === 1 } }; } /** * Revoke refresh token (logout) * @param {string} refreshToken - Token to revoke * @returns {Promise} True if revoked, false if not found */ async logout(refreshToken) { const db = getDb(); // Find and revoke token const tokens = db.prepare('SELECT id, token_hash, user_id FROM refresh_tokens WHERE revoked = 0').all(); for (const record of tokens) { const matches = await bcrypt.compare(refreshToken, record.token_hash); if (matches) { db.prepare('UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE id = ?') .run(Date.now(), record.id); await AuditService.logEvent('logout', record.user_id, null, null, 'success', {}); return true; } } return false; } /** * Generate new access token from refresh token * @param {string} refreshToken * @returns {Promise<{accessToken: string}>} * @throws {Error} If refresh token invalid, expired, or revoked */ async refreshAccessToken(refreshToken) { const db = getDb(); const now = Date.now(); // Find valid refresh token const tokens = db.prepare(` SELECT id, token_hash, user_id, expires_at, revoked FROM refresh_tokens WHERE expires_at > ? AND revoked = 0 `).all(now); let validToken = null; for (const record of tokens) { const matches = await bcrypt.compare(refreshToken, record.token_hash); if (matches) { validToken = record; break; } } if (!validToken) { throw new Error('Invalid or expired refresh token'); } // Get user const user = db.prepare('SELECT id, email, name, email_verified, status FROM users WHERE id = ?') .get(validToken.user_id); if (!user || user.status !== 'active') { throw new Error('User account not active'); } // Generate new access token const accessToken = jwt.sign( { sub: user.id, email: user.email, name: user.name, emailVerified: user.email_verified === 1 }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: 'navidocs-api' } ); return { accessToken }; } /** * Initiate password reset flow * @param {string} email * @returns {Promise<{resetToken: string}>} Token to send via email */ async forgotPassword(email) { const db = getDb(); const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email); // Don't reveal if email exists (security) if (!user) { return { resetToken: null }; // Silently fail } // Generate reset token const resetToken = uuidv4(); const tokenHash = await bcrypt.hash(resetToken, BCRYPT_ROUNDS); const expiresAt = Date.now() + (60 * 60 * 1000); // 1 hour const tokenId = uuidv4(); db.prepare(` INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?) `).run(tokenId, user.id, tokenHash, expiresAt, Date.now()); await AuditService.logEvent('password_reset_requested', user.id, null, null, 'success', {}); return { resetToken }; // In production, send via email and return { success: true } } /** * Complete password reset * @param {string} resetToken * @param {string} newPassword * @returns {Promise} * @throws {Error} If token invalid, expired, or used */ async resetPassword(resetToken, newPassword) { this._validatePassword(newPassword); const db = getDb(); const now = Date.now(); // Find valid reset token const tokens = db.prepare(` SELECT id, token_hash, user_id, expires_at, used FROM password_reset_tokens WHERE expires_at > ? AND used = 0 `).all(now); let validToken = null; for (const record of tokens) { const matches = await bcrypt.compare(resetToken, record.token_hash); if (matches) { validToken = record; break; } } if (!validToken) { throw new Error('Invalid or expired reset token'); } // Hash new password const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); // Update password db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') .run(passwordHash, now, validToken.user_id); // Mark token as used db.prepare('UPDATE password_reset_tokens SET used = 1, used_at = ? WHERE id = ?') .run(now, validToken.id); // Revoke all refresh tokens (force re-login) db.prepare('UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE user_id = ?') .run(now, validToken.user_id); await AuditService.logEvent('password_reset_completed', validToken.user_id, null, null, 'success', {}); return true; } /** * Verify email address * @param {string} verificationToken * @returns {Promise} * @throws {Error} If token invalid or expired */ async verifyEmail(verificationToken) { const db = getDb(); const now = Date.now(); const user = db.prepare(` SELECT id FROM users WHERE email_verification_token = ? AND email_verification_expires > ? `).get(verificationToken, now); if (!user) { throw new Error('Invalid or expired verification token'); } db.prepare(` UPDATE users SET email_verified = 1, email_verification_token = NULL, email_verification_expires = NULL WHERE id = ? `).run(user.id); await AuditService.logEvent('email_verified', user.id, null, null, 'success', {}); return true; } // Private validation methods _validateEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { throw new Error('Invalid email format'); } } _validatePassword(password) { if (password.length < 8) { throw new Error('Password must be at least 8 characters'); } // Require at least one uppercase, one lowercase, one number const hasUppercase = /[A-Z]/.test(password); const hasLowercase = /[a-z]/.test(password); const hasNumber = /[0-9]/.test(password); if (!hasUppercase || !hasLowercase || !hasNumber) { throw new Error('Password must contain uppercase, lowercase, and number'); } } } export default new AuthService(); ``` **Acceptance Criteria:** - [ ] User can register with email/password/name - [ ] Duplicate email registration fails with clear error - [ ] Password is hashed with bcrypt (cost=12) - [ ] Email verification token generated and stored - [ ] Personal organization created for new user - [ ] User can login with valid credentials - [ ] Login returns JWT access token and refresh token - [ ] Invalid credentials rejected with generic error message - [ ] Suspended account login denied - [ ] Refresh token can generate new access token - [ ] Expired/revoked refresh tokens rejected - [ ] Password reset token generated and validated - [ ] Reset password updates hash and revokes all sessions - [ ] Email verification marks user as verified - [ ] All operations logged to audit table - [ ] All passwords validated for strength - [ ] Unit tests cover 95%+ of code paths **Testing:** ```javascript // /test/services/auth.test.js describe('AuthService', () => { describe('register', () => { it('creates user with hashed password', async () => {}); it('creates personal organization', async () => {}); it('rejects duplicate email', async () => {}); it('rejects weak password', async () => {}); it('rejects invalid email', async () => {}); }); describe('login', () => { it('returns tokens for valid credentials', async () => {}); it('rejects invalid password', async () => {}); it('rejects non-existent user', async () => {}); it('rejects suspended account', async () => {}); it('updates last_login_at', async () => {}); }); describe('refreshAccessToken', () => { it('generates new access token', async () => {}); it('rejects expired refresh token', async () => {}); it('rejects revoked refresh token', async () => {}); }); describe('forgotPassword', () => { it('generates reset token', async () => {}); it('does not reveal non-existent email', async () => {}); }); describe('resetPassword', () => { it('updates password hash', async () => {}); it('revokes all refresh tokens', async () => {}); it('rejects used token', async () => {}); it('rejects expired token', async () => {}); }); describe('verifyEmail', () => { it('marks email as verified', async () => {}); it('rejects invalid token', async () => {}); }); }); ``` **Files Changed:** - `/services/auth.js` (new) - `/test/services/auth.test.js` (new) --- ### Task 1.3: Audit Service **Branch:** `feat/audit-service` **Estimated Effort:** 3 hours **Priority:** P0 (Blocker) **Dependencies:** Task 1.1 **Module Interface:** ```javascript // /services/audit.js import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../db/db.js'; export class AuditService { /** * Log a security or business event * @param {string} eventType - Event category (login, logout, permission_grant, etc) * @param {string|null} userId - User who triggered event (null for anonymous) * @param {string|null} resourceType - Type of resource affected (entity, document, organization) * @param {string|null} resourceId - ID of affected resource * @param {string} status - Outcome: success, failure, denied * @param {Object} metadata - Additional context (ip_address, user_agent, details) * @returns {Promise} Audit log entry ID */ static async logEvent(eventType, userId, resourceType, resourceId, status, metadata = {}) { const db = getDb(); const auditId = uuidv4(); const now = Date.now(); db.prepare(` INSERT INTO audit_log ( id, user_id, event_type, resource_type, resource_id, ip_address, user_agent, status, metadata, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( auditId, userId, eventType, resourceType, resourceId, metadata.ip_address || null, metadata.user_agent || null, status, JSON.stringify(metadata), now ); return auditId; } /** * Query audit logs with filters * @param {Object} filters - { userId, eventType, resourceType, startDate, endDate, status } * @param {Object} pagination - { limit, offset } * @returns {Promise<{logs: Array, total: number}>} */ static async queryLogs(filters = {}, pagination = { limit: 100, offset: 0 }) { const db = getDb(); let query = 'SELECT * FROM audit_log WHERE 1=1'; const params = []; if (filters.userId) { query += ' AND user_id = ?'; params.push(filters.userId); } if (filters.eventType) { query += ' AND event_type = ?'; params.push(filters.eventType); } if (filters.resourceType) { query += ' AND resource_type = ?'; params.push(filters.resourceType); } if (filters.status) { query += ' AND status = ?'; params.push(filters.status); } if (filters.startDate) { query += ' AND created_at >= ?'; params.push(filters.startDate); } if (filters.endDate) { query += ' AND created_at <= ?'; params.push(filters.endDate); } query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; params.push(pagination.limit, pagination.offset); const logs = db.prepare(query).all(...params); // Get total count let countQuery = 'SELECT COUNT(*) as total FROM audit_log WHERE 1=1'; const countParams = params.slice(0, -2); // Remove limit/offset if (filters.userId) countQuery += ' AND user_id = ?'; if (filters.eventType) countQuery += ' AND event_type = ?'; if (filters.resourceType) countQuery += ' AND resource_type = ?'; if (filters.status) countQuery += ' AND status = ?'; if (filters.startDate) countQuery += ' AND created_at >= ?'; if (filters.endDate) countQuery += ' AND created_at <= ?'; const { total } = db.prepare(countQuery).get(...countParams); return { logs: logs.map(log => ({ ...log, metadata: JSON.parse(log.metadata || '{}') })), total }; } /** * Get recent events for a user (for security dashboard) * @param {string} userId * @param {number} limit * @returns {Promise} */ static async getUserRecentEvents(userId, limit = 20) { const db = getDb(); const logs = db.prepare(` SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ? `).all(userId, limit); return logs.map(log => ({ ...log, metadata: JSON.parse(log.metadata || '{}') })); } /** * Clean up old audit logs (retention policy) * @param {number} retentionDays - Delete logs older than this many days * @returns {Promise} Number of deleted records */ static async cleanupOldLogs(retentionDays = 90) { const db = getDb(); const cutoffDate = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); const result = db.prepare('DELETE FROM audit_log WHERE created_at < ?').run(cutoffDate); return result.changes; } } export default AuditService; ``` **Acceptance Criteria:** - [ ] Events logged with all required fields - [ ] Metadata stored as JSON - [ ] Query filters work correctly (user, event type, date range) - [ ] Pagination works correctly - [ ] Recent events for user retrieved - [ ] Old logs cleaned up based on retention policy - [ ] Unit tests cover all methods **Testing:** ```javascript // /test/services/audit.test.js describe('AuditService', () => { it('logs event with metadata', async () => {}); it('queries logs by user', async () => {}); it('queries logs by event type', async () => {}); it('queries logs by date range', async () => {}); it('returns paginated results', async () => {}); it('gets user recent events', async () => {}); it('cleans up old logs', async () => {}); }); ``` **Files Changed:** - `/services/audit.js` (new) - `/test/services/audit.test.js` (new) --- ### Task 1.4: Authentication Routes **Branch:** `feat/auth-routes` **Estimated Effort:** 6 hours **Priority:** P0 (Blocker) **Dependencies:** Task 1.2, Task 1.3 **API Endpoints:** ```javascript // /routes/auth.js import express from 'express'; import rateLimit from 'express-rate-limit'; import AuthService from '../services/auth.js'; import { authenticateToken } from '../middleware/auth.js'; const router = express.Router(); // Rate limiter for auth endpoints (stricter than general API) const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 requests per window message: 'Too many authentication attempts, please try again later' }); /** * POST /api/auth/register * Create new user account * * Body: { email, password, name } * Returns: { user: { id, email, name, emailVerified } } */ router.post('/register', authLimiter, async (req, res) => { try { const { email, password, name } = req.body; if (!email || !password || !name) { return res.status(400).json({ error: 'Missing required fields', required: ['email', 'password', 'name'] }); } const result = await AuthService.register(email, password, name); // In production, send verification email here // await EmailService.sendVerificationEmail(result.email, result.verificationToken); res.status(201).json({ user: { id: result.id, email: result.email, name: result.name, emailVerified: result.emailVerified }, message: 'Registration successful. Please check your email to verify your account.' }); } catch (error) { if (error.message === 'Email already registered') { return res.status(409).json({ error: error.message }); } if (error.message.includes('Password') || error.message.includes('email')) { return res.status(400).json({ error: error.message }); } console.error('Registration error:', error); res.status(500).json({ error: 'Registration failed' }); } }); /** * POST /api/auth/login * Authenticate user * * Body: { email, password } * Returns: { accessToken, refreshToken, user } */ router.post('/login', authLimiter, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Missing required fields', required: ['email', 'password'] }); } const deviceInfo = { userAgent: req.headers['user-agent'], ipAddress: req.ip }; const result = await AuthService.login(email, password, deviceInfo); res.json(result); } catch (error) { if (error.message === 'Invalid credentials' || error.message === 'Account suspended') { return res.status(401).json({ error: error.message }); } console.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); /** * POST /api/auth/logout * Revoke refresh token * * Body: { refreshToken } * Returns: { success: true } */ router.post('/logout', async (req, res) => { try { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ error: 'Missing refresh token' }); } await AuthService.logout(refreshToken); res.json({ success: true }); } catch (error) { console.error('Logout error:', error); res.status(500).json({ error: 'Logout failed' }); } }); /** * POST /api/auth/refresh * Get new access token * * Body: { refreshToken } * Returns: { accessToken } */ router.post('/refresh', async (req, res) => { try { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ error: 'Missing refresh token' }); } const result = await AuthService.refreshAccessToken(refreshToken); res.json(result); } catch (error) { return res.status(401).json({ error: 'Invalid or expired refresh token' }); } }); /** * POST /api/auth/forgot-password * Request password reset * * Body: { email } * Returns: { success: true } */ router.post('/forgot-password', authLimiter, async (req, res) => { try { const { email } = req.body; if (!email) { return res.status(400).json({ error: 'Missing email' }); } await AuthService.forgotPassword(email); // Always return success (don't reveal if email exists) res.json({ success: true, message: 'If that email exists, a password reset link has been sent.' }); } catch (error) { console.error('Forgot password error:', error); res.status(500).json({ error: 'Failed to process request' }); } }); /** * POST /api/auth/reset-password * Complete password reset * * Body: { resetToken, newPassword } * Returns: { success: true } */ router.post('/reset-password', authLimiter, async (req, res) => { try { const { resetToken, newPassword } = req.body; if (!resetToken || !newPassword) { return res.status(400).json({ error: 'Missing required fields', required: ['resetToken', 'newPassword'] }); } await AuthService.resetPassword(resetToken, newPassword); res.json({ success: true, message: 'Password reset successful. Please login with your new password.' }); } catch (error) { if (error.message.includes('token') || error.message.includes('Password')) { return res.status(400).json({ error: error.message }); } console.error('Reset password error:', error); res.status(500).json({ error: 'Failed to reset password' }); } }); /** * GET /api/auth/verify-email/:token * Verify email address * * Params: { token } * Returns: { success: true } */ router.get('/verify-email/:token', async (req, res) => { try { const { token } = req.params; await AuthService.verifyEmail(token); res.json({ success: true, message: 'Email verified successfully. You can now login.' }); } catch (error) { return res.status(400).json({ error: 'Invalid or expired verification token' }); } }); /** * GET /api/auth/me * Get current user profile * * Headers: Authorization: Bearer * Returns: { user } */ router.get('/me', authenticateToken, async (req, res) => { try { // User already attached to req.user by middleware res.json({ user: { id: req.user.sub, email: req.user.email, name: req.user.name, emailVerified: req.user.emailVerified } }); } catch (error) { console.error('Get user error:', error); res.status(500).json({ error: 'Failed to get user' }); } }); export default router; ``` **Acceptance Criteria:** - [ ] All endpoints return correct status codes - [ ] Input validation rejects missing fields - [ ] Rate limiting enforced (5 req/15min for auth endpoints) - [ ] Error messages are user-friendly (don't expose system details) - [ ] Registration creates user and returns user object - [ ] Login returns access + refresh tokens - [ ] Logout revokes refresh token - [ ] Refresh generates new access token - [ ] Password reset flow works end-to-end - [ ] Email verification marks user as verified - [ ] /me endpoint returns current user - [ ] Integration tests cover all endpoints - [ ] API documentation generated (OpenAPI/Swagger) **Testing:** ```javascript // /test/routes/auth.test.js describe('Auth Routes', () => { describe('POST /api/auth/register', () => { it('creates user', async () => {}); it('rejects duplicate email', async () => {}); it('rejects missing fields', async () => {}); it('enforces rate limiting', async () => {}); }); describe('POST /api/auth/login', () => { it('returns tokens for valid credentials', async () => {}); it('rejects invalid credentials', async () => {}); it('enforces rate limiting', async () => {}); }); // ... more tests }); ``` **Files Changed:** - `/routes/auth.js` (new) - `/index.js` (add auth routes) - `/test/routes/auth.test.js` (new) - `/API_SUMMARY.md` (document new endpoints) --- ### Task 1.5: Enhanced Auth Middleware **Branch:** `feat/auth-middleware-enhanced` **Estimated Effort:** 3 hours **Priority:** P0 (Blocker) **Dependencies:** Task 1.2 **Middleware Updates:** ```javascript // /middleware/auth.js (REPLACE EXISTING) import jwt from 'jsonwebtoken'; import { getDb } from '../db/db.js'; import AuditService from '../services/audit.js'; const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret-here-change-in-production'; /** * Verify JWT token and attach user to request * @param {Request} req - Express request * @param {Response} res - Express response * @param {Function} next - Next middleware */ export async function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN if (!token) { return res.status(401).json({ error: 'Authentication required', message: 'No access token provided' }); } try { // Verify JWT signature and expiry const decoded = jwt.verify(token, JWT_SECRET, { issuer: 'navidocs-api' }); // Check if user still exists and is active const db = getDb(); const user = db.prepare('SELECT id, email, status FROM users WHERE id = ?').get(decoded.sub); if (!user) { await AuditService.logEvent('auth_failed', null, null, null, 'failure', { reason: 'user_not_found', token_sub: decoded.sub, ip_address: req.ip }); return res.status(403).json({ error: 'Invalid token', message: 'User account not found' }); } if (user.status !== 'active') { await AuditService.logEvent('auth_denied', user.id, null, null, 'denied', { reason: 'account_not_active', status: user.status, ip_address: req.ip }); return res.status(403).json({ error: 'Account not active', message: 'Your account has been suspended or deleted' }); } // Attach user to request req.user = { id: decoded.sub, email: decoded.email, name: decoded.name, emailVerified: decoded.emailVerified }; next(); } catch (error) { // Log failed authentication attempts await AuditService.logEvent('auth_failed', null, null, null, 'failure', { reason: error.name, // TokenExpiredError, JsonWebTokenError, etc ip_address: req.ip, user_agent: req.headers['user-agent'] }); if (error.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Token expired', message: 'Please refresh your access token' }); } return res.status(403).json({ error: 'Invalid token', message: 'Token verification failed' }); } } /** * Optional authentication - attaches user if token present * @param {Request} req * @param {Response} res * @param {Function} next */ export async function optionalAuth(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (token) { try { const decoded = jwt.verify(token, JWT_SECRET, { issuer: 'navidocs-api' }); const db = getDb(); const user = db.prepare('SELECT id, email, status FROM users WHERE id = ?').get(decoded.sub); if (user && user.status === 'active') { req.user = { id: decoded.sub, email: decoded.email, name: decoded.name, emailVerified: decoded.emailVerified }; } } catch (error) { // Silently fail for optional auth console.log('Optional auth failed:', error.message); } } next(); } export default { authenticateToken, optionalAuth }; ``` **Acceptance Criteria:** - [ ] Valid JWT grants access - [ ] Expired JWT returns 401 with clear message - [ ] Invalid JWT returns 403 - [ ] Missing token returns 401 - [ ] Suspended user denied access - [ ] Deleted user denied access - [ ] Non-existent user denied access - [ ] User object attached to req.user - [ ] Failed attempts logged to audit - [ ] optionalAuth doesn't fail on invalid token - [ ] Unit tests cover all error cases **Testing:** ```javascript // /test/middleware/auth.test.js describe('Auth Middleware', () => { describe('authenticateToken', () => { it('allows valid token', async () => {}); it('rejects expired token', async () => {}); it('rejects invalid signature', async () => {}); it('rejects missing token', async () => {}); it('rejects deleted user', async () => {}); it('rejects suspended user', async () => {}); it('logs failed attempts', async () => {}); }); describe('optionalAuth', () => { it('attaches user if valid token', async () => {}); it('continues without user if invalid token', async () => {}); }); }); ``` **Files Changed:** - `/middleware/auth.js` (replace) - `/test/middleware/auth.test.js` (new) --- ## Phase 2: Authorization ### Task 2.1: Authorization Service **Branch:** `feat/authorization-service` **Estimated Effort:** 8 hours **Priority:** P1 **Dependencies:** Task 1.1 **Module Interface:** ```javascript // /services/authorization.js import { getDb } from '../db/db.js'; import LRU from 'lru-cache'; // Permission hierarchy (higher level includes all lower permissions) const PERMISSION_HIERARCHY = { viewer: ['view'], editor: ['view', 'edit', 'create'], manager: ['view', 'edit', 'create', 'delete', 'share'], admin: ['view', 'edit', 'create', 'delete', 'share', 'manage_users', 'manage_permissions'] }; // Organization role capabilities const ORG_ROLE_PERMISSIONS = { viewer: ['view'], // Read-only access to all org entities member: ['view', 'edit'], // Edit assigned entities only manager: ['view', 'edit', 'create', 'delete'], // Manage all entities admin: ['view', 'edit', 'create', 'delete', 'manage_users', 'manage_permissions'] // Full control }; // Permission cache (LRU) const permissionCache = new LRU({ max: 10000, ttl: 1000 * 60 * 5, // 5 minutes }); export class AuthorizationService { /** * Check if user has permission on an entity * Resolution order: * 1. Organization admin/manager roles (full access) * 2. Entity-level permissions (granular) * 3. Document-level permissions (legacy support) * * @param {string} userId * @param {string} entityId * @param {string} requiredPermission - view, edit, create, delete, etc * @returns {Promise} */ static async checkEntityPermission(userId, entityId, requiredPermission) { const cacheKey = `entity:${userId}:${entityId}:${requiredPermission}`; const cached = permissionCache.get(cacheKey); if (cached !== undefined) { return cached; } const result = await this._checkEntityPermissionUncached(userId, entityId, requiredPermission); permissionCache.set(cacheKey, result); return result; } static async _checkEntityPermissionUncached(userId, entityId, requiredPermission) { const db = getDb(); // Step 1: Check organization-level roles const orgMembership = db.prepare(` SELECT uo.role FROM user_organizations uo INNER JOIN entities e ON e.organization_id = uo.organization_id WHERE uo.user_id = ? AND e.id = ? `).get(userId, entityId); if (orgMembership) { // Org admin has full access if (orgMembership.role === 'admin') { return true; } // Org manager has all permissions except user management if (orgMembership.role === 'manager') { const allowedPerms = ORG_ROLE_PERMISSIONS.manager; if (allowedPerms.includes(requiredPermission)) { return true; } } // Org viewer has read-only access if (orgMembership.role === 'viewer') { return requiredPermission === 'view'; } // Org member needs entity-level permission (check below) } else { // Not a member of the organization - deny return false; } // Step 2: Check entity-level permissions const entityPerm = db.prepare(` SELECT permission_level FROM entity_permissions WHERE user_id = ? AND entity_id = ? AND (expires_at IS NULL OR expires_at > ?) `).get(userId, entityId, Date.now()); if (entityPerm) { const allowedPerms = PERMISSION_HIERARCHY[entityPerm.permission_level]; return allowedPerms.includes(requiredPermission); } // Step 3: Check legacy permissions table (backward compatibility) const resourcePerm = db.prepare(` SELECT permission FROM permissions WHERE user_id = ? AND resource_type = 'entity' AND resource_id = ? AND (expires_at IS NULL OR expires_at > ?) `).get(userId, entityId, Date.now()); if (resourcePerm) { if (resourcePerm.permission === 'admin') return true; return resourcePerm.permission === requiredPermission; } return false; } /** * Check organization permission * @param {string} userId * @param {string} organizationId * @param {string} requiredPermission * @returns {Promise} */ static async checkOrganizationPermission(userId, organizationId, requiredPermission) { const cacheKey = `org:${userId}:${organizationId}:${requiredPermission}`; const cached = permissionCache.get(cacheKey); if (cached !== undefined) { return cached; } const db = getDb(); const membership = db.prepare(` SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ? `).get(userId, organizationId); if (!membership) { permissionCache.set(cacheKey, false); return false; } const allowedPerms = ORG_ROLE_PERMISSIONS[membership.role]; const result = allowedPerms.includes(requiredPermission); permissionCache.set(cacheKey, result); return result; } /** * Check document permission * @param {string} userId * @param {string} documentId * @param {string} requiredPermission * @returns {Promise} */ static async checkDocumentPermission(userId, documentId, requiredPermission) { const db = getDb(); // Get document's entity const document = db.prepare(` SELECT entity_id, organization_id, uploaded_by FROM documents WHERE id = ? `).get(documentId); if (!document) { return false; } // Owner always has full access if (document.uploaded_by === userId) { return true; } // Check organization membership if (document.organization_id) { const hasOrgPerm = await this.checkOrganizationPermission( userId, document.organization_id, requiredPermission ); if (hasOrgPerm) return true; } // Check entity permission if (document.entity_id) { const hasEntityPerm = await this.checkEntityPermission( userId, document.entity_id, requiredPermission ); if (hasEntityPerm) return true; } // Check document shares const share = db.prepare(` SELECT permission FROM document_shares WHERE document_id = ? AND shared_with = ? `).get(documentId, userId); if (share) { if (share.permission === 'write') return true; if (share.permission === 'read' && requiredPermission === 'view') return true; } return false; } /** * Grant entity permission to user * @param {string} granterId - User granting permission (must have manage_permissions) * @param {string} userId - User receiving permission * @param {string} entityId * @param {string} permissionLevel - viewer, editor, manager, admin * @param {number|null} expiresAt - Unix timestamp or null for permanent * @returns {Promise} Permission ID * @throws {Error} If granter lacks permission */ static async grantEntityPermission(granterId, userId, entityId, permissionLevel, expiresAt = null) { // Validate permission level if (!PERMISSION_HIERARCHY[permissionLevel]) { throw new Error(`Invalid permission level: ${permissionLevel}`); } // Check if granter has permission to grant const canGrant = await this.checkEntityPermission(granterId, entityId, 'manage_permissions'); if (!canGrant) { throw new Error('You do not have permission to grant access to this entity'); } // Cannot grant higher permission than you have const granterPerm = await this._getEntityPermissionLevel(granterId, entityId); if (!this._canGrantPermission(granterPerm, permissionLevel)) { throw new Error('Cannot grant higher permission level than your own'); } const db = getDb(); const { v4: uuidv4 } = await import('uuid'); const permId = uuidv4(); const now = Date.now(); // Upsert entity permission db.prepare(` INSERT INTO entity_permissions (id, user_id, entity_id, permission_level, granted_by, granted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, entity_id) DO UPDATE SET permission_level = excluded.permission_level, granted_by = excluded.granted_by, granted_at = excluded.granted_at, expires_at = excluded.expires_at `).run(permId, userId, entityId, permissionLevel, granterId, now, expiresAt); // Invalidate cache this._invalidateEntityCache(userId, entityId); return permId; } /** * Revoke entity permission * @param {string} granterId - User revoking permission * @param {string} userId - User whose permission is being revoked * @param {string} entityId * @returns {Promise} True if revoked, false if not found */ static async revokeEntityPermission(granterId, userId, entityId) { const canRevoke = await this.checkEntityPermission(granterId, entityId, 'manage_permissions'); if (!canRevoke) { throw new Error('You do not have permission to revoke access to this entity'); } const db = getDb(); const result = db.prepare(` DELETE FROM entity_permissions WHERE user_id = ? AND entity_id = ? `).run(userId, entityId); if (result.changes > 0) { this._invalidateEntityCache(userId, entityId); return true; } return false; } /** * Get all users with access to an entity * @param {string} entityId * @returns {Promise} List of users with permission levels */ static async getEntityUsers(entityId) { const db = getDb(); // Get entity's organization const entity = db.prepare('SELECT organization_id FROM entities WHERE id = ?').get(entityId); if (!entity) { throw new Error('Entity not found'); } // Get org members const orgMembers = db.prepare(` SELECT u.id, u.email, u.name, uo.role as org_role FROM user_organizations uo INNER JOIN users u ON u.id = uo.user_id WHERE uo.organization_id = ? `).all(entity.organization_id); // Get entity-specific permissions const entityPerms = db.prepare(` SELECT ep.user_id, ep.permission_level, ep.granted_at, ep.expires_at FROM entity_permissions ep WHERE ep.entity_id = ? `).all(entityId); const entityPermsMap = {}; entityPerms.forEach(perm => { entityPermsMap[perm.user_id] = perm; }); return orgMembers.map(member => ({ userId: member.id, email: member.email, name: member.name, organizationRole: member.org_role, entityPermission: entityPermsMap[member.id]?.permission_level || null, grantedAt: entityPermsMap[member.id]?.granted_at || null, expiresAt: entityPermsMap[member.id]?.expires_at || null })); } /** * Get all entities a user has access to * @param {string} userId * @returns {Promise} List of entities with permission levels */ static async getUserEntities(userId) { const db = getDb(); // Get all organizations user is member of const orgs = db.prepare(` SELECT organization_id, role FROM user_organizations WHERE user_id = ? `).all(userId); if (orgs.length === 0) { return []; } const orgIds = orgs.map(o => o.organization_id); const placeholders = orgIds.map(() => '?').join(','); // Get all entities in those organizations const entities = db.prepare(` SELECT e.id, e.name, e.entity_type, e.organization_id FROM entities e WHERE e.organization_id IN (${placeholders}) `).all(...orgIds); // Get entity-specific permissions const entityPerms = db.prepare(` SELECT entity_id, permission_level FROM entity_permissions WHERE user_id = ? `).all(userId); const entityPermsMap = {}; entityPerms.forEach(perm => { entityPermsMap[perm.entity_id] = perm.permission_level; }); const orgRolesMap = {}; orgs.forEach(org => { orgRolesMap[org.organization_id] = org.role; }); return entities.map(entity => { const orgRole = orgRolesMap[entity.organization_id]; const entityPerm = entityPermsMap[entity.id]; // Determine effective permission let effectivePermission = null; if (orgRole === 'admin') { effectivePermission = 'admin'; } else if (orgRole === 'manager') { effectivePermission = 'manager'; } else if (entityPerm) { effectivePermission = entityPerm; } else if (orgRole === 'viewer') { effectivePermission = 'viewer'; } return { entityId: entity.id, name: entity.name, entityType: entity.entity_type, organizationId: entity.organization_id, organizationRole: orgRole, entityPermission: entityPerm || null, effectivePermission }; }).filter(e => e.effectivePermission !== null); // Only return entities user can access } // Private helper methods static async _getEntityPermissionLevel(userId, entityId) { const db = getDb(); const orgMembership = db.prepare(` SELECT uo.role FROM user_organizations uo INNER JOIN entities e ON e.organization_id = uo.organization_id WHERE uo.user_id = ? AND e.id = ? `).get(userId, entityId); if (orgMembership && (orgMembership.role === 'admin' || orgMembership.role === 'manager')) { return orgMembership.role; } const entityPerm = db.prepare(` SELECT permission_level FROM entity_permissions WHERE user_id = ? AND entity_id = ? `).get(userId, entityId); return entityPerm?.permission_level || null; } static _canGrantPermission(granterLevel, targetLevel) { const levels = ['viewer', 'editor', 'manager', 'admin']; const granterIndex = levels.indexOf(granterLevel); const targetIndex = levels.indexOf(targetLevel); return granterIndex >= targetIndex; } static _invalidateEntityCache(userId, entityId) { // Invalidate all permission combinations for this user+entity const permissions = ['view', 'edit', 'create', 'delete', 'share', 'manage_users', 'manage_permissions']; permissions.forEach(perm => { permissionCache.delete(`entity:${userId}:${entityId}:${perm}`); }); } /** * Clear permission cache (useful for testing or after bulk updates) */ static clearCache() { permissionCache.clear(); } } export default AuthorizationService; ``` **Acceptance Criteria:** - [ ] Org admin has full access to all org entities - [ ] Org manager can view/edit/create/delete all org entities - [ ] Org member sees only entities with explicit permissions - [ ] Org viewer has read-only access - [ ] Entity-level permissions override org permissions (more granular) - [ ] Permission hierarchy works (admin > manager > editor > viewer) - [ ] Grant permission validates granter's authority - [ ] Cannot grant higher permission than own level - [ ] Revoke permission removes access immediately - [ ] Get entity users returns all users with access - [ ] Get user entities returns all accessible entities - [ ] Permission cache improves performance (>10x faster on cache hit) - [ ] Cache invalidated on permission changes - [ ] Unit tests cover all methods (95%+ coverage) **Testing:** ```javascript // /test/services/authorization.test.js describe('AuthorizationService', () => { describe('checkEntityPermission', () => { it('grants access to org admin', async () => {}); it('grants access to org manager', async () => {}); it('grants access to entity admin', async () => {}); it('denies access to non-member', async () => {}); it('respects permission hierarchy', async () => {}); it('checks expiry date', async () => {}); }); describe('grantEntityPermission', () => { it('grants permission', async () => {}); it('rejects if granter lacks permission', async () => {}); it('rejects granting higher permission', async () => {}); it('invalidates cache', async () => {}); }); // More tests... }); ``` **Files Changed:** - `/services/authorization.js` (new) - `/test/services/authorization.test.js` (new) --- *[Continued in next section due to length...]* --- ## Summary This implementation plan breaks down the multi-tenancy auth system into 16 granular PRs across 5 phases: **Phase 1 (Week 1):** Foundation - Database schema, core auth services, routes, middleware **Phase 2 (Week 2):** Authorization - Permission service, middleware, route protection **Phase 3 (Week 3):** User & Org Management - CRUD operations, member management **Phase 4 (Week 4):** Security - Rate limiting, brute force protection, testing **Phase 5 (Week 5):** Documentation - API docs, migration scripts, deployment Each task includes: - Clear module boundaries and interfaces - Comprehensive acceptance criteria - Testing requirements - Estimated effort - File change list This ensures a systematic, testable, and reviewable implementation process.