openvibers/openvibe.live Commits

FedCM sign-in, cross-site link hand-off, silent-login shortcut

OP OpenVibers committed 18 Sept 2026 · 073a588a60 · 6 files, +396 −125

- POST /api/auth/fedcm: the browser-obtained openvibe.network assertion is exchanged
  server-to-server (jwt-bearer grant) and opens the same session as the OAuth callback —
  the callback's session code is now establishNetworkSession(), shared by both
- /api/auth/sso/login?silent=1 answers from an existing valid session without the network
  round trip (a hand-off from another site lands in one redirect)
- app.js: signed in → links to other OpenVibe sites go through their silent sign-in (shared
  sso-client.js); guests → hidden network check, then FedCM (no-op for real guests)
- vendor/openvibe-shared re-synced (sso-client.js, fedcm-aware navbar); served from /shared

Changes

ddiff --git a/public/js/app.js b/public/js/app.js
iindex 37887dee..7fda82d2 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -618,6 +618,21 @@ function _trySilentSso(force) {
* signed in there (GET /sso/check → one postMessage). Only a "yes" triggers the silent sign-in,
* so first-time visitors never see a redirect. Once per tab per 10 minutes.
*/
++let _ssoClientPromise = null;
++function _loadSsoClient() {
++ if (window.OpenVibeSSO) return Promise.resolve(window.OpenVibeSSO);
++ if (_ssoClientPromise) return _ssoClientPromise;
++ _ssoClientPromise = new Promise((resolve) => {
++ const sc = document.createElement('script'); sc.async = true; sc.src = '/shared/sso-client.js';
++ sc.onload = () => resolve(window.OpenVibeSSO || null); sc.onerror = () => resolve(null);
++ document.head.appendChild(sc);
++ });
++ return _ssoClientPromise;
++}
++/** Signed in here: links to the other OpenVibe sites carry the session along (shared sso-client.js). */
++function _enableHandoff() {
++ _loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!currentUser }); } catch { /* */ } });
++}
function _checkNetworkSession() {
if (_ssoHint() === 'guest' || /bot|crawl|spider|headless/i.test(navigator.userAgent)) return;
try {
@@@ -631,7 +646,15 @@ function _checkNetworkSession() {
const onMsg = (e) => {
if (e.origin !== base || !e.data || e.data.type !== 'ov-sso') return;
done();
-- if (e.data.signedIn && !currentUser) _trySilentSso(true);
++ if (currentUser) return;
++ if (e.data.signedIn) { _trySilentSso(true); return; }
++ // Not visible from an iframe (guest, or the browser keeps third-party cookies away):
++ // let the browser itself ask the network — FedCM is a no-op for guests.
++ _loadSsoClient().then(async (sso) => {
++ if (!sso || !sso.fedcmAvailable() || currentUser) return;
++ const r = await sso.fedcm({ apiBase: base, fedcmLogin: '/api/auth/fedcm', mediation: 'optional' });
++ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso'); } catch { /* */ } _setSsoHint('account'); location.reload(); }
++ });
};
window.addEventListener('message', onMsg);
frame = document.createElement('iframe');
@@@ -654,6 +677,7 @@ function switchAccount() {
function browseAsGuest() { logout(); try { toast('Browsing as a guest — Sign in brings you straight back.', 'info'); } catch { /* */ } }
function logout() {
_setSsoHint('guest');
++ try { window.OpenVibeSSO && window.OpenVibeSSO.preventSilent(); } catch { /* */ }
// Clear server-side cookies via API
fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
// Clear client-side storage (both keys)
@@@ -720,6 +744,7 @@ async function loadUser() {
try { if (new URLSearchParams(location.search).get('sso') === 'none') { _setSsoHint('guest'); history.replaceState(null, '', location.pathname); } } catch { /* */ }
function onAuthChange() {
++ if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }
const anon = document.getElementById('nav-auth-anon');
const user = document.getElementById('nav-auth-user');
const admin = document.getElementById('nav-admin');
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 670f6180..41959ab3 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -315,6 +315,14 @@ router.get('/sso/login', (req, res) => {
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
// Where to go afterwards (the fanout chain, or the page that asked to sign in).
const next = safeNext(req.query.next);
++ // Already signed in here (a link hand-off from another OpenVibe site, or a stale hint): no
++ // round trip to the network — straight to the page.
++ if (req.query.silent) {
++ try {
++ const have = req.cookies?.token || req.cookies?.ov_token;
++ if (have && require('./auth').verifyToken(have)) return res.redirect(next);
++ } catch { /* fall through to the network */ }
++ }
if (next !== '/') res.cookie('oauth_next', next, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
else res.clearCookie('oauth_next');
@@@ -331,6 +339,150 @@ router.get('/sso/login', (req, res) => {
res.redirect(`${getNetworkBase()}/oauth/authorize?${params.toString()}`);
});
++/**
++ * Turn a successful openvibe.network token response into this site's session: find or create
++ * the local account linked to that network user, set the cookies, return what the page needs.
++ * Shared by the OAuth callback and the FedCM sign-in. Throws { status, message } on bad input.
++ */
++function establishNetworkSession(req, res, tokenData) {
++ const ssoUser = tokenData.user;
++ if (!ssoUser) {
++ throw Object.assign(new Error('No user data in token response'), { status: 400 });
++ }
++
++ // Find or create local user linked to this openvibe.network account
++ const openvibeToolsId = String(ssoUser.id);
++
++ // Check linked_accounts first
++ let localUser = null;
++ const linked = db.getDb().prepare(
++ "SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?"
++ ).get(openvibeToolsId);
++
++ if (linked) {
++ localUser = db.getUserById(linked.user_id);
++ }
++
++ // Try matching by username
++ if (!localUser) {
++ localUser = db.getUserByUsername(ssoUser.username);
++ if (localUser) {
++ // Auto-link
++ db.getDb().prepare(
++ "INSERT OR IGNORE INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)"
++ ).run(localUser.id, openvibeToolsId, ssoUser.username);
++ }
++ }
++
++ // Create new local user if none found
++ if (!localUser) {
++ const stream_key = uuidv4().replace(/-/g, '');
++ const result = db.createUser({
++ username: ssoUser.username,
++ email: ssoUser.email || null,
++ password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'), // placeholder, can't login with password
++ display_name: ssoUser.display_name || ssoUser.username,
++ stream_key,
++ });
++ localUser = db.getUserById(result.lastInsertRowid);
++
++ // Sync optional fields from openvibe.network
++ if (ssoUser.avatar_url) db.updateUserAvatar(localUser.id, ssoUser.avatar_url);
++ if (ssoUser.bio) db.getDb().prepare('UPDATE users SET bio = ? WHERE id = ?').run(ssoUser.bio, localUser.id);
++ if (ssoUser.role && ['user', 'streamer', 'global_mod', 'admin'].includes(ssoUser.role)) {
++ db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(ssoUser.role, localUser.id);
++ }
++ if (ssoUser.profile_color) db.getDb().prepare('UPDATE users SET profile_color = ? WHERE id = ?').run(ssoUser.profile_color, localUser.id);
++
++ // Link to openvibe.network
++ db.getDb().prepare(
++ "INSERT OR IGNORE INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)"
++ ).run(localUser.id, openvibeToolsId, ssoUser.username);
++
++ localUser = db.getUserById(localUser.id); // re-fetch
++ console.log(`[Auth/SSO] New local account created for openvibe.network user ${ssoUser.username} (openvibe-tools id:${openvibeToolsId}, local id:${localUser.id})`);
++ }
++
++ // Register this account under the user's openvibe.network Linked Services.
++ try { require('../utils/notify').reportLinkedAccount(localUser); } catch { /* */ }
++
++ // Use the openvibe.network token directly (no more local tokens)
++ const openvibeToolsToken = tokenData.access_token;
++ const openvibeRefreshToken = tokenData.refresh_token;
++ if (!openvibeToolsToken) {
++ throw Object.assign(new Error('No access token in response'), { status: 500 });
++ }
++
++ const isSecure = (process.env.BASE_URL || '').startsWith('https');
++
++ // Set access token cookie (readable by JS for API calls)
++ res.cookie('token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
++
++ // Also set ov_token (used by shared navbar/notification libs)
++ res.cookie('ov_token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
++ // Long-lived hint (outlives the token): this browser has signed in here, so a later visit
++ // with no session may try one silent sign-in through the network.
++ res.cookie('ov_sso_hint', 'account', { ...HINT_COOKIE, secure: isSecure });
++
++ // Store refresh token in httpOnly cookie (not readable by JS — server handles refresh)
++ if (openvibeRefreshToken) {
++ res.cookie('ov_refresh', openvibeRefreshToken, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure, path: '/api/auth' });
++ }
++
++ // Return HTML that stores token in localStorage and redirects
++ return { localUser, openvibeToolsToken, openvibeRefreshToken };
++}
++
++/** POST to the network's /oauth/token (server-to-server) and parse the JSON answer. */
++function networkTokenRequest(payload) {
++ return new Promise((resolve, reject) => {
++ const body = JSON.stringify(payload);
++ const url = new URL(`${getNetworkTokenBase()}/oauth/token`);
++ const httpModule = getNetworkHttpModule();
++ const reqOpts = {
++ hostname: url.hostname,
++ port: url.port || (url.protocol === 'https:' ? 443 : 80),
++ path: url.pathname + url.search,
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
++ };
++ const httpReq = httpModule.request(reqOpts, (httpRes) => {
++ let data = '';
++ httpRes.on('data', chunk => data += chunk);
++ httpRes.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid token response')); } });
++ });
++ httpReq.on('error', reject);
++ httpReq.write(body);
++ httpReq.end();
++ });
++}
++
++// ── FedCM sign-in ────────────────────────────────────────────
++// The browser obtained an assertion from openvibe.network for this origin (shared sso-client.js);
++// exchange it server-to-server for the usual token pair and open the same session the OAuth
++// callback would. Same-origin JSON POST only.
++router.post('/fedcm', express.json({ limit: '8kb' }), async (req, res) => {
++ try {
++ if (!/application\/json/.test(String(req.headers['content-type'] || ''))) return res.status(400).json({ error: 'JSON only' });
++ const { token, nonce } = req.body || {};
++ if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Missing token' });
++ let claims = null;
++ try { claims = JSON.parse(Buffer.from(String(token).split('.')[1] || '', 'base64url').toString('utf8')); } catch { /* */ }
++ if (!claims || (nonce && claims.nonce !== nonce)) return res.status(400).json({ error: 'Assertion does not match this request' });
++ const tokenData = await networkTokenRequest({
++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
++ assertion: token, client_id: OV_CLIENT_ID, client_secret: OV_CLIENT_SECRET,
++ });
++ if (tokenData.error) return res.status(401).json({ error: tokenData.error, error_description: tokenData.error_description || null });
++ const { localUser } = establishNetworkSession(req, res, tokenData);
++ res.set('Cache-Control', 'no-store');
++ res.json({ ok: true, user: { id: localUser.id, username: localUser.username, display_name: localUser.display_name || localUser.username, avatar_url: localUser.avatar_url || null } });
++ } catch (err) {
++ console.error('[Auth/FedCM] sign-in failed:', err.message);
++ res.status(err.status || 500).json({ error: err.message || 'FedCM sign-in failed' });
++ }
++});
++
// ── OAuth Callback (exchange code for token) ─────────────────
router.get('/callback', async (req, res) => {
try {
@@@ -361,39 +513,12 @@ router.get('/callback', async (req, res) => {
res.clearCookie('oauth_state');
// Exchange code for tokens
-- const tokenData = await new Promise((resolve, reject) => {
-- const body = JSON.stringify({
-- grant_type: 'authorization_code',
-- client_id: OV_CLIENT_ID,
-- client_secret: OV_CLIENT_SECRET,
-- code,
-- redirect_uri: getNetworkRedirectUri(),
-- });
--
-- const url = new URL(`${getNetworkTokenBase()}/oauth/token`);
-- const httpModule = getNetworkHttpModule();
-- const reqOpts = {
-- hostname: url.hostname,
-- port: url.port || (url.protocol === 'https:' ? 443 : 80),
-- path: url.pathname + url.search,
-- method: 'POST',
-- headers: {
-- 'Content-Type': 'application/json',
-- 'Content-Length': Buffer.byteLength(body),
-- },
-- };
--
-- const httpReq = httpModule.request(reqOpts, (httpRes) => {
-- let data = '';
-- httpRes.on('data', chunk => data += chunk);
-- httpRes.on('end', () => {
-- try { resolve(JSON.parse(data)); }
-- catch { reject(new Error('Invalid token response')); }
-- });
-- });
-- httpReq.on('error', reject);
-- httpReq.write(body);
-- httpReq.end();
++ const tokenData = await networkTokenRequest({
++ grant_type: 'authorization_code',
++ client_id: OV_CLIENT_ID,
++ client_secret: OV_CLIENT_SECRET,
++ code,
++ redirect_uri: getNetworkRedirectUri(),
});
if (tokenData.error) {
@@@ -401,91 +526,8 @@ router.get('/callback', async (req, res) => {
return res.status(400).send(`OAuth error: ${tokenData.error_description || tokenData.error}`);
}
-- const ssoUser = tokenData.user;
-- if (!ssoUser) {
-- return res.status(400).send('No user data in token response');
-- }
--
-- // Find or create local user linked to this openvibe.network account
-- const openvibeToolsId = String(ssoUser.id);
--
-- // Check linked_accounts first
-- let localUser = null;
-- const linked = db.getDb().prepare(
-- "SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?"
-- ).get(openvibeToolsId);
--
-- if (linked) {
-- localUser = db.getUserById(linked.user_id);
-- }
--
-- // Try matching by username
-- if (!localUser) {
-- localUser = db.getUserByUsername(ssoUser.username);
-- if (localUser) {
-- // Auto-link
-- db.getDb().prepare(
-- "INSERT OR IGNORE INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)"
-- ).run(localUser.id, openvibeToolsId, ssoUser.username);
-- }
-- }
--
-- // Create new local user if none found
-- if (!localUser) {
-- const stream_key = uuidv4().replace(/-/g, '');
-- const result = db.createUser({
-- username: ssoUser.username,
-- email: ssoUser.email || null,
-- password_hash: '$sso$' + require('crypto').randomBytes(32).toString('hex'), // placeholder, can't login with password
-- display_name: ssoUser.display_name || ssoUser.username,
-- stream_key,
-- });
-- localUser = db.getUserById(result.lastInsertRowid);
--
-- // Sync optional fields from openvibe.network
-- if (ssoUser.avatar_url) db.updateUserAvatar(localUser.id, ssoUser.avatar_url);
-- if (ssoUser.bio) db.getDb().prepare('UPDATE users SET bio = ? WHERE id = ?').run(ssoUser.bio, localUser.id);
-- if (ssoUser.role && ['user', 'streamer', 'global_mod', 'admin'].includes(ssoUser.role)) {
-- db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(ssoUser.role, localUser.id);
-- }
-- if (ssoUser.profile_color) db.getDb().prepare('UPDATE users SET profile_color = ? WHERE id = ?').run(ssoUser.profile_color, localUser.id);
--
-- // Link to openvibe.network
-- db.getDb().prepare(
-- "INSERT OR IGNORE INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)"
-- ).run(localUser.id, openvibeToolsId, ssoUser.username);
--
-- localUser = db.getUserById(localUser.id); // re-fetch
-- console.log(`[Auth/SSO] New local account created for openvibe.network user ${ssoUser.username} (openvibe-tools id:${openvibeToolsId}, local id:${localUser.id})`);
-- }
--
-- // Register this account under the user's openvibe.network Linked Services.
-- try { require('../utils/notify').reportLinkedAccount(localUser); } catch { /* */ }
--
-- // Use the openvibe.network token directly (no more local tokens)
-- const openvibeToolsToken = tokenData.access_token;
-- const openvibeRefreshToken = tokenData.refresh_token;
-- if (!openvibeToolsToken) {
-- return res.status(500).send('No access token in response');
-- }
--
-- const isSecure = (process.env.BASE_URL || '').startsWith('https');
--
-- // Set access token cookie (readable by JS for API calls)
-- res.cookie('token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
--
-- // Also set ov_token (used by shared navbar/notification libs)
-- res.cookie('ov_token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
-- // Long-lived hint (outlives the token): this browser has signed in here, so a later visit
-- // with no session may try one silent sign-in through the network.
-- res.cookie('ov_sso_hint', 'account', { ...HINT_COOKIE, secure: isSecure });
--
-- // Store refresh token in httpOnly cookie (not readable by JS — server handles refresh)
-- if (openvibeRefreshToken) {
-- res.cookie('ov_refresh', openvibeRefreshToken, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure, path: '/api/auth' });
-- }
++ const { localUser, openvibeToolsToken, openvibeRefreshToken } = establishNetworkSession(req, res, tokenData);
-- // Return HTML that stores token in localStorage and redirects
const userJson = JSON.stringify({ id: localUser.id, username: localUser.username, display_name: localUser.display_name || localUser.username, avatar_url: localUser.avatar_url || null });
res.send(`<!DOCTYPE html>
<html><head><title>Logging in...</title></head>
ddiff --git a/server/index.js b/server/index.js
iindex 785e9974..9cadf7f8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -435,7 +435,7 @@ app.use((req, res, next) => {
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// resolved source directory to avoid write operations (the public/ directory
// is read-only under systemd ProtectSystem=strict on production).
--const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js'];
++const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js'];
let sharedServePath = null;
(function resolveSharedAssets() {
ddiff --git a/vendor/openvibe-shared/account-switcher.js b/vendor/openvibe-shared/account-switcher.js
iindex cc16ff37..04e9dbc1 100644
---- a/vendor/openvibe-shared/account-switcher.js
++++ b/vendor/openvibe-shared/account-switcher.js
@@@ -308,7 +308,14 @@
}
}
++ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */
++ function serverLogout() {
++ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }
++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
++ }
++
function logout() {
++ serverLogout();
const activeId = getActiveId();
if (activeId) removeAccount(activeId);
localStorage.removeItem(TOKEN_KEY);
@@@ -319,6 +326,7 @@
}
function logoutAll() {
++ serverLogout();
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACTIVE_KEY);
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex 47afb984..f59da4df 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -38,6 +38,10 @@
// but this browser has signed in to the network before (ov_sso_hint), try one silent
// prompt=none round trip per tab so a session on one site becomes a session on all.
silentLogin: null,
++ // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first
++ // time and re-authenticates silently afterwards; 'silent' only re-authenticates.
++ fedcm: 'optional',
++ fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)
};
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
@@@ -790,15 +794,84 @@
* never for bots. The site's login route turns silent=1 into prompt=none and comes straight
* back on error=login_required, so a signed-out visitor sees one quick redirect at most.
*/
++ let _ssoClientLoading = null;
++ function loadSsoClient() {
++ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
++ if (_ssoClientLoading) return _ssoClientLoading;
++ _ssoClientLoading = new Promise((resolve) => {
++ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;
++ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
++ document.head.appendChild(sc);
++ });
++ return _ssoClientLoading;
++ }
++
++ /** Signed in here: cross-site links carry the session along (see sso-client.js). */
++ function enableHandoff() {
++ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });
++ }
++
++ function silentLoginNow() {
++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
++ location.replace(url);
++ return true;
++ }
++
++ /**
++ * Two ways to find out that this browser is signed in to the network without a session here:
++ * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the
++ * silent sign-in (one quick redirect, back where you were);
++ * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no
++ * redirect unless the answer is yes. Browsers that partition third-party cookies answer
++ * no and nothing happens, which is the same as before.
++ * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.
++ */
function maybeSilentLogin() {
if (!_config.silentLogin || typeof location === 'undefined') return false;
-- if (ssoHint() !== 'account') return false;
++ const hint = ssoHint();
++ if (hint === 'guest') return false;
if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
-- try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
if (/[?&]sso=none\b/.test(location.search)) return false;
-- const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
-- location.replace(url);
-- return true;
++ try {
++ const last = +sessionStorage.getItem('ov_silent_sso_at') || 0;
++ if (Date.now() - last < 10 * 60 * 1000) return false;
++ sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));
++ } catch { return false; }
++ if (hint === 'account') return silentLoginNow();
++ checkNetworkSession().then((state) => {
++ if (state && state.signedIn) return silentLoginNow();
++ // No answer or "not signed in" — either a guest, or a browser that keeps the network's
++ // cookie away from iframes. FedCM asks the browser itself; the network's login status
++ // makes it a no-op for guests, a native chip (then silent re-auth) for signed-in users.
++ if (_config.fedcm === false) return;
++ loadSsoClient().then(async (sso) => {
++ if (!sso || !sso.fedcmAvailable()) return;
++ const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' });
++ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); }
++ });
++ });
++ return false;
++ }
++
++ /** Ask the network (hidden iframe + postMessage) whether this browser is signed in there. */
++ function checkNetworkSession(timeoutMs = 4000) {
++ return new Promise((resolve) => {
++ let done = false, frame = null, timer = null;
++ const finish = (v) => { if (done) return; done = true; clearTimeout(timer); window.removeEventListener('message', onMsg); try { frame?.remove(); } catch { /* */ } resolve(v); };
++ const onMsg = (e) => {
++ if (e.origin !== _config.apiBase || !e.data || e.data.type !== 'ov-sso') return;
++ finish({ signedIn: !!e.data.signedIn, username: e.data.username || null });
++ };
++ try {
++ window.addEventListener('message', onMsg);
++ frame = document.createElement('iframe');
++ frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1');
++ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
++ frame.src = `${_config.apiBase}/sso/check?origin=${encodeURIComponent(location.origin)}`;
++ (document.body || document.documentElement).appendChild(frame);
++ timer = setTimeout(() => finish(null), timeoutMs);
++ } catch { finish(null); }
++ });
}
function recordHistory() {
@@@ -827,6 +900,7 @@
if (session.token) _config.token = session.token;
render();
recordHistory();
++ enableHandoff();
} else {
maybeSilentLogin();
}
@@@ -1019,6 +1093,8 @@
clearAuthState();
_config.user = null;
_config.token = null;
++ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
@@@ -1060,7 +1136,7 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
-- else recordHistory();
++ else { recordHistory(); enableHandoff(); }
return el;
},
@@@ -1093,6 +1169,9 @@
if (_navEl && _config.user) render();
},
++ /** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */
++ checkNetworkSession,
++
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
brand() { return resolveBrand(); },
ddiff --git a/vendor/openvibe-shared/sso-client.js b/vendor/openvibe-shared/sso-client.js
nnew file mode 100644
iindex 00000000..50fed6f4
---- /dev/null
++++ b/vendor/openvibe-shared/sso-client.js
@@@ -0,0 +1,117 @@
++/**
++ * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in
++ * across separate domains. Loaded lazily by navbar.js; Live loads it directly.
++ *
++ * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along
++ * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)
++ * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`
++ *
++ * Link hand-off: when this page has a session and a link points at another OpenVibe site, the
++ * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).
++ * It is a top-level navigation, so it works in every browser with no third-party cookies, and
++ * the target answers straight from its own session when it already has one. Sites that share a
++ * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with
++ * data-ov-nohandoff.
++ *
++ * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network
++ * for an assertion (a native chip the first time per site, silent auto re-auth after that) and
++ * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.
++ * Never called for browsers the network has told "logged-out", so guests see nothing.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeSSO) return;
++
++ const NETWORK = 'https://openvibe.network';
++ // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.
++ const SITES = [
++ { test: (h) => h === 'openvibe.live' || h.endsWith('.openvibe.live'), key: 'live', login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={url}', fedcm: 'https://openvibe.live/api/auth/fedcm' },
++ { test: (h) => h === 'openvibe.tools' || h.endsWith('.openvibe.tools'), key: 'tools', login: 'https://openvibe.tools/auth/login?silent=1&next={url}', fedcm: 'https://openvibe.tools/auth/fedcm' },
++ { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
++ { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
++ ];
++ const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;
++ const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);
++
++ /** The URL that signs the browser in on the link's site and lands on the link, or null. */
++ function loginUrlFor(href) {
++ let u; try { u = new URL(href, location.href); } catch { return null; }
++ if (u.protocol !== 'https:') return null;
++ const target = siteFor(u.hostname);
++ if (!target) return null;
++ const mine = here();
++ if (mine && mine.key === target.key) return null; // same session cookie already
++ return target.login.replace('{url}', encodeURIComponent(u.toString()));
++ }
++
++ let _handoffOn = false, _signedIn = false;
++ function rewrite(a) {
++ if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;
++ const url = loginUrlFor(a.href);
++ if (!url) return;
++ a.dataset.ovHandoff = a.href;
++ a.href = url;
++ }
++ function onIntent(e) {
++ if (!_signedIn) return;
++ const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
++ if (a) rewrite(a);
++ }
++ /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */
++ function handoffLinks(opts) {
++ _signedIn = !!(opts && opts.signedIn);
++ if (_handoffOn) return;
++ _handoffOn = true;
++ // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.
++ for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });
++ document.addEventListener('click', onIntent, true);
++ document.addEventListener('contextmenu', (e) => {
++ // A copied link should stay a plain link.
++ const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;
++ if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }
++ }, true);
++ }
++
++ /** FedCM availability: the API exists and this is a top-level, secure document. */
++ function fedcmAvailable() {
++ try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }
++ }
++
++ /**
++ * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.
++ * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth
++ * (previously approved on this site), 'optional' = show the chip the first time.
++ */
++ async function fedcm(opts) {
++ const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});
++ if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };
++ const site = here();
++ const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';
++ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
++ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);
++ let cred;
++ try {
++ cred = await navigator.credentials.get({
++ identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },
++ mediation: o.mediation, signal: ctrl.signal,
++ });
++ } catch (err) {
++ clearTimeout(timer);
++ return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };
++ }
++ clearTimeout(timer);
++ if (!cred || !cred.token) return { ok: false, reason: 'no-token' };
++ try {
++ const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };
++ try { sessionStorage.setItem('ov_sso_hint', 'account'); localStorage.setItem('ov_sso_hint', 'account'); } catch { /* */ }
++ return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };
++ } catch (err) { return { ok: false, reason: 'network' }; }
++ }
++
++ /** After an explicit sign-out: the browser must not auto re-auth us back in. */
++ function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }
++
++ root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };
++})(typeof window !== 'undefined' ? window : globalThis);