From 5a4cfcbf649fdad4d405b62dd3404a00fa29df3c Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 4 Sep 2026 13:40:33 +0000 Subject: [PATCH] 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 / 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) --- backend/db.js | 85 ++++++++++++++++++++++++- backend/routes/auth.js | 103 +++++++++++++++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/pages/AyonEntry.tsx | 56 +++++++++++++++++ 4 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/AyonEntry.tsx diff --git a/backend/db.js b/backend/db.js index 93b2d26..0b5acd3 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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 + * `/`, 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, diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e56719b..aa315e4 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 585b8d2..3bb17a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import CollectionList from './pages/CollectionList'; import CollectionDetail from './pages/CollectionDetail'; import Editor from './pages/Editor'; import Admin from './pages/Admin'; +import AyonEntry from './pages/AyonEntry'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); @@ -32,6 +33,7 @@ function AppRoutes() { return ( } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/AyonEntry.tsx b/frontend/src/pages/AyonEntry.tsx new file mode 100644 index 0000000..b014d38 --- /dev/null +++ b/frontend/src/pages/AyonEntry.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../auth'; +import api from '../api'; + +/** + * AYON entry point: /b?ticket=…&project=…&task=… + * Redeems the single-use ticket issued by the AYON addon, stores the + * session token and forwards to the task board. No login form involved. + */ +export default function AyonEntry() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const { login } = useAuth(); + const [error, setError] = useState(''); + + useEffect(() => { + const ticket = params.get('ticket'); + if (!ticket) { + setError('No ticket provided. Open RefBoard from the AYON launcher.'); + return; + } + + api.post('/api/auth/ayon/exchange', { ticket }) + .then((res) => { + if (res.data?.token && res.data?.user) { + login(res.data.token, res.data.user); + navigate(res.data.board_url || '/', { replace: true }); + } else { + setError('Unexpected response from server.'); + } + }) + .catch((err) => { + setError(err?.response?.data?.error || 'Ticket exchange failed.'); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const style: React.CSSProperties = { + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + height: '100vh', background: '#1a1a1a', color: '#e0e0e0', gap: '12px', + }; + + return ( +
+ {error ? ( + <> +
{error}
+ Go to login + + ) : ( +
Signing you in via AYON…
+ )} +
+ ); +}