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:
Hermes
2026-09-04 13:40:33 +00:00
parent 33d3109136
commit 5a4cfcbf64
4 changed files with 243 additions and 3 deletions
+82 -3
View File
@@ -429,6 +429,10 @@ function createCollection({ id, name, description, createdBy }) {
return getCollection(id);
}
function getCollectionByName(name) {
return db.prepare('SELECT * FROM collections WHERE name = ?').get(name);
}
function updateCollection(collectionId, { name, description, isPublic, shareToken }) {
const fields = [];
const params = [];
@@ -816,13 +820,88 @@ async function seedAdminFromEnv() {
console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`);
}
// ---------------------
// AYON integration helpers
// ---------------------
// Collection that holds all AYON task boards.
const AYON_COLLECTION_NAME = 'AYON';
/**
* Get-or-create the internal user row backing an AYON identity.
* AYON users never log in here — the row only exists so that boards,
* images, threads and comments (which reference users.id) keep working.
* Password hash is an unusable sentinel.
*/
function getOrCreateAyonUser(username, displayName) {
const existing = getUserByUsername(username);
if (existing) return existing;
const email = `${username}@ayon.local`;
const byEmail = getUserByEmail(email);
if (byEmail) return byEmail;
return createUser({
id: uuidv4(),
email,
username,
passwordHash: `!ayon:${uuidv4()}`, // no password ever matches this
displayName: displayName || username,
role: 'member',
});
}
/**
* Get-or-create the board for an AYON task: board name is
* `<project>/<task_path>`, living in the shared "AYON" collection.
* Returns { board, collection, created }.
*/
function getOrCreateAyonBoard(project, taskPath) {
const name = `${project}/${taskPath}`;
let collection = getCollectionByName(AYON_COLLECTION_NAME);
if (!collection) {
// Bootstrap user owns the container collection
const owner = getOrCreateAyonUser('ayon-system', 'AYON System');
collection = createCollection({
id: uuidv4(),
name: AYON_COLLECTION_NAME,
description: 'Task boards created by the AYON integration',
createdBy: owner.id,
});
addCollectionMember(collection.id, owner.id, 'owner');
}
const ownerId = collection.created_by;
const existing = db.prepare(
'SELECT * FROM boards WHERE collection_id = ? AND name = ?'
).get(collection.id, name);
if (existing) return { board: existing, collection, created: false };
const board = createBoard({
id: uuidv4(),
collectionId: collection.id,
name,
description: `AYON task board (${name})`,
createdBy: ownerId,
});
return { board, collection, created: true };
}
/** Grant a user editor membership on the AYON collection (idempotent). */
function grantAyonCollectionAccess(userId) {
const collection = getCollectionByName(AYON_COLLECTION_NAME);
if (!collection) return;
const member = getCollectionMember(collection.id, userId);
if (!member) addCollectionMember(collection.id, userId, 'editor');
}
module.exports = {
db,
// AYON integration
getOrCreateAyonUser, getOrCreateAyonBoard, grantAyonCollectionAccess,
// Users
getUserByEmail, getUserById, getUserByUsername,
createUser,
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
// Collections
getCollections, getCollection, getCollectionByShareToken,
createCollection, updateCollection, deleteCollection,
getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember,
+103
View File
@@ -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;