const { Router } = require('express'); const { v4: uuidv4 } = require('uuid'); const { getUserByEmail, getUserByUsername, createUser, getUserById, getUserCount, getBoolSetting, } = require('../db'); const { hashPassword, comparePassword, generateToken, authMiddleware, } = require('../auth'); const router = Router(); /** * GET /api/auth/config * Public, no auth — used by the Login page to decide whether to show the * "Register" link. Only exposes booleans the UI needs; no internals. */ router.get('/config', (_req, res) => { try { const userCount = getUserCount(); return res.json({ allowSelfRegistration: getBoolSetting('allow_self_registration', false), hasUsers: userCount > 0, }); } catch (err) { console.error('[auth] config error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); /** * POST /api/auth/register * Create a new user. The first user automatically becomes admin. */ router.post('/register', async (req, res) => { try { const { email, username, password, display_name, displayName } = req.body; const dn = display_name || displayName; if (!email || !username || !password) { return res.status(400).json({ error: 'Email, username, and password are required' }); } if (password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } const userCount = getUserCount(); const allowRegistration = getBoolSetting('allow_self_registration', false); if (!allowRegistration && userCount > 0) { return res.status(403).json({ error: 'Self-registration is disabled. Ask an admin to create your account.', }); } const existing = getUserByEmail(email); if (existing) { return res.status(409).json({ error: 'Email already registered' }); } const existingUsername = getUserByUsername(username); if (existingUsername) { return res.status(409).json({ error: 'Username already taken' }); } const passwordHash = await hashPassword(password); const role = userCount === 0 ? 'admin' : 'member'; const user = createUser({ id: uuidv4(), email, username, passwordHash, displayName: dn || username, role, }); const token = generateToken(user); return res.status(201).json({ user: { id: user.id, email: user.email, username: user.username, display_name: user.display_name, role: user.role, created_at: user.created_at, }, token, }); } catch (err) { console.error('[auth] register error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); /** * POST /api/auth/login * Authenticate with email + password, receive JWT. */ router.post('/login', async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } const user = getUserByEmail(email); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } const valid = await comparePassword(password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'Invalid credentials' }); } const token = generateToken(user); return res.json({ user: { id: user.id, email: user.email, username: user.username, display_name: user.display_name, role: user.role, created_at: user.created_at, }, token, }); } catch (err) { console.error('[auth] login error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); /** * GET /api/auth/me * Return the current authenticated user. */ router.get('/me', authMiddleware, (req, res) => { try { const user = getUserById(req.user.id); if (!user) { return res.status(404).json({ error: 'User not found' }); } return res.json({ id: user.id, email: user.email, username: user.username, display_name: user.display_name, role: user.role, created_at: user.created_at, }); } catch (err) { console.error('[auth] me error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); /** * PUT /api/auth/password * Change password (requires current password). */ router.put('/password', authMiddleware, async (req, res) => { try { const { current_password, new_password } = req.body; if (!current_password || !new_password) { return res.status(400).json({ error: 'Current password and new password are required' }); } if (new_password.length < 6) { return res.status(400).json({ error: 'New password must be at least 6 characters' }); } const user = getUserById(req.user.id); if (!user) { return res.status(404).json({ error: 'User not found' }); } const valid = await comparePassword(current_password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'Current password is incorrect' }); } const { updateUserPassword } = require('../db'); const passwordHash = await hashPassword(new_password); updateUserPassword(user.id, passwordHash); return res.json({ message: 'Password updated successfully' }); } catch (err) { console.error('[auth] password change error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // --------------------- // AYON single-sign-on // --------------------- // Flow: the AYON addon issues a single-use ticket bound to (user, project, // task). The browser presents the ticket here; we redeem it against the // addon's exchange endpoint (server-to-server, API key) and mint our own // session JWT. The ticket is transport, never identity. const AYON_EXCHANGE_TIMEOUT_MS = 8000; async function redeemTicket(ticket) { const apiKey = process.env.REFBOARD_API_KEY || ''; const exchangeUrl = process.env.AYON_EXCHANGE_URL || ''; if (!apiKey || !exchangeUrl) { throw Object.assign(new Error('AYON exchange not configured'), { status: 503 }); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), AYON_EXCHANGE_TIMEOUT_MS); let resp; try { resp = await fetch(exchangeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, }, body: JSON.stringify({ ticket }), signal: controller.signal, }); } catch (err) { throw Object.assign(new Error('AYON exchange unreachable'), { status: 502 }); } finally { clearTimeout(timer); } if (resp.status === 404) { // Ticket unknown or already redeemed (single-use) throw Object.assign(new Error('Invalid or expired ticket'), { status: 401 }); } if (!resp.ok) { throw Object.assign(new Error(`AYON exchange failed (${resp.status})`), { status: 502 }); } return resp.json(); } /** * POST /api/auth/ayon/exchange * Server-to-server style redemption: { ticket } → session token + user. * Also used by the browser entry route below. */ router.post('/ayon/exchange', async (req, res) => { try { const { ticket } = req.body || {}; if (!ticket || typeof ticket !== 'string') { return res.status(400).json({ error: 'ticket is required' }); } const payload = await redeemTicket(ticket.trim()); // Expected payload from the addon: { ayon_user, display_name, project, task } const ayonUser = payload.ayon_user; if (!ayonUser) { return res.status(502).json({ error: 'AYON exchange returned no identity' }); } const { getOrCreateAyonUser, getOrCreateAyonBoard, grantAyonCollectionAccess, } = require('../db'); const user = getOrCreateAyonUser(ayonUser, payload.display_name); grantAyonCollectionAccess(user.id); let boardUrl = null; if (payload.project && payload.task) { const { board } = getOrCreateAyonBoard(payload.project, payload.task); boardUrl = `/board/${board.id}`; } const token = generateToken(user); return res.json({ token, user: { id: user.id, email: user.email, username: user.username, display_name: user.display_name, role: user.role, }, board_url: boardUrl, }); } catch (err) { if (err.status) { return res.status(err.status).json({ error: err.message }); } console.error('[auth] ayon exchange error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); /** * GET /auth/ayon/login?ticket=… (browser entry point, hits the SPA route) * The frontend AyonEntry page calls POST /api/auth/ayon/exchange with the * ticket, stores the token and redirects to the task board. */ module.exports = router;