feat: AYON single-sign-on (ticket exchange, task boards, browser entry)
- 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)
This commit is contained in:
@@ -202,4 +202,107 @@ router.put('/password', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user