- POST /api/auth/ayon/exchange: redeem single-use ticket (issued by the AYON addon) via AYON_EXCHANGE_URL, mint session JWT, get-or-create internal user row and the <project>/<task> board in the AYON collection - db: getOrCreateAyonUser / getOrCreateAyonBoard / grantAyonCollectionAccess - frontend: /b route redeems ticket from URL and forwards to the board - password login/register paths untouched (legacy instance support)
309 lines
8.7 KiB
JavaScript
309 lines
8.7 KiB
JavaScript
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;
|