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:
+82
-3
@@ -429,6 +429,10 @@ function createCollection({ id, name, description, createdBy }) {
|
|||||||
return getCollection(id);
|
return getCollection(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCollectionByName(name) {
|
||||||
|
return db.prepare('SELECT * FROM collections WHERE name = ?').get(name);
|
||||||
|
}
|
||||||
|
|
||||||
function updateCollection(collectionId, { name, description, isPublic, shareToken }) {
|
function updateCollection(collectionId, { name, description, isPublic, shareToken }) {
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -816,13 +820,88 @@ async function seedAdminFromEnv() {
|
|||||||
console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`);
|
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 = {
|
module.exports = {
|
||||||
db,
|
db,
|
||||||
|
// AYON integration
|
||||||
|
getOrCreateAyonUser, getOrCreateAyonBoard, grantAyonCollectionAccess,
|
||||||
// Users
|
// Users
|
||||||
getUserByEmail, getUserById, getUserByUsername,
|
getUserByEmail, getUserById, getUserByUsername,
|
||||||
createUser,
|
|
||||||
getAllUsers, updateUserPassword, deactivateUser, getUserCount,
|
|
||||||
// Collections
|
|
||||||
getCollections, getCollection, getCollectionByShareToken,
|
getCollections, getCollection, getCollectionByShareToken,
|
||||||
createCollection, updateCollection, deleteCollection,
|
createCollection, updateCollection, deleteCollection,
|
||||||
getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember,
|
getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember,
|
||||||
|
|||||||
@@ -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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CollectionList from './pages/CollectionList';
|
|||||||
import CollectionDetail from './pages/CollectionDetail';
|
import CollectionDetail from './pages/CollectionDetail';
|
||||||
import Editor from './pages/Editor';
|
import Editor from './pages/Editor';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
|
import AyonEntry from './pages/AyonEntry';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -32,6 +33,7 @@ function AppRoutes() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/b" element={<AyonEntry />} />
|
||||||
<Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} />
|
||||||
<Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} />
|
<Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} />
|
||||||
<Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
|
<Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={style}>
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '18px' }}>{error}</div>
|
||||||
|
<a href="/login" style={{ color: '#8ab4f8' }}>Go to login</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '18px' }}>Signing you in via AYON…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user