openvibers/openvibe.live Commits
FedCM sign-in, cross-site link hand-off, silent-login shortcut
Changes
@@@ -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 APIfetch('/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');@@@ -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 redirectsconst 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>@@@ -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() {@@@ -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);@@@ -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(); },nnew file mode 100644@@@ -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);
@@ -618,6 +618,21 @@ function _trySilentSso(force) {
@@ -618,6 +618,21 @@ function _trySilentSso(force) {
* signed in there (GET /sso/check → one postMessage). Only a "yes" triggers the silent sign-in,
* 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.
* 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() {
function _checkNetworkSession() {
if (_ssoHint() === 'guest' || /bot|crawl|spider|headless/i.test(navigator.userAgent)) return;
if (_ssoHint() === 'guest' || /bot|crawl|spider|headless/i.test(navigator.userAgent)) return;
try {
try {
@@ -631,7 +646,15 @@ function _checkNetworkSession() {
@@ -631,7 +646,15 @@ function _checkNetworkSession() {
const onMsg = (e) => {
const onMsg = (e) => {
if (e.origin !== base || !e.data || e.data.type !== 'ov-sso') return;
if (e.origin !== base || !e.data || e.data.type !== 'ov-sso') return;
done();
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);
window.addEventListener('message', onMsg);
frame = document.createElement('iframe');
frame = document.createElement('iframe');
@@ -654,6 +677,7 @@ function switchAccount() {
@@ -654,6 +677,7 @@ function switchAccount() {
function browseAsGuest() { logout(); try { toast('Browsing as a guest — Sign in brings you straight back.', 'info'); } catch { /* */ } }
function browseAsGuest() { logout(); try { toast('Browsing as a guest — Sign in brings you straight back.', 'info'); } catch { /* */ } }
function logout() {
function logout() {
_setSsoHint('guest');
_setSsoHint('guest');
+ try { window.OpenVibeSSO && window.OpenVibeSSO.preventSilent(); } catch { /* */ }
// Clear server-side cookies via API
// Clear server-side cookies via API
fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
// Clear client-side storage (both keys)
// Clear client-side storage (both keys)
@@ -720,6 +744,7 @@ async function loadUser() {
@@ -720,6 +744,7 @@ async function loadUser() {
try { if (new URLSearchParams(location.search).get('sso') === 'none') { _setSsoHint('guest'); history.replaceState(null, '', location.pathname); } } catch { /* */ }
try { if (new URLSearchParams(location.search).get('sso') === 'none') { _setSsoHint('guest'); history.replaceState(null, '', location.pathname); } } catch { /* */ }
function onAuthChange() {
function onAuthChange() {
+ if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }
const anon = document.getElementById('nav-auth-anon');
const anon = document.getElementById('nav-auth-anon');
const user = document.getElementById('nav-auth-user');
const user = document.getElementById('nav-auth-user');
const admin = document.getElementById('nav-admin');
const admin = document.getElementById('nav-admin');
@@ -315,6 +315,14 @@ router.get('/sso/login', (req, res) => {
@@ -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 });
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).
// Where to go afterwards (the fanout chain, or the page that asked to sign in).
const next = safeNext(req.query.next);
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 });
if (next !== '/') res.cookie('oauth_next', next, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
else res.clearCookie('oauth_next');
else res.clearCookie('oauth_next');
@@ -331,6 +339,150 @@ router.get('/sso/login', (req, res) => {
@@ -331,6 +339,150 @@ router.get('/sso/login', (req, res) => {
res.redirect(`${getNetworkBase()}/oauth/authorize?${params.toString()}`);
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) ─────────────────
// ── OAuth Callback (exchange code for token) ─────────────────
router.get('/callback', async (req, res) => {
router.get('/callback', async (req, res) => {
try {
try {
@@ -361,39 +513,12 @@ router.get('/callback', async (req, res) => {
@@ -361,39 +513,12 @@ router.get('/callback', async (req, res) => {
res.clearCookie('oauth_state');
res.clearCookie('oauth_state');
// Exchange code for tokens
// 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) {
if (tokenData.error) {
@@ -401,91 +526,8 @@ router.get('/callback', async (req, res) => {
@@ -401,91 +526,8 @@ router.get('/callback', async (req, res) => {
return res.status(400).send(`OAuth error: ${tokenData.error_description || tokenData.error}`);
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 });
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>
res.send(`<!DOCTYPE html>
<html><head><title>Logging in...</title></head>
<html><head><title>Logging in...</title></head>
@@ -435,7 +435,7 @@ app.use((req, res, next) => {
@@ -435,7 +435,7 @@ app.use((req, res, next) => {
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// resolved source directory to avoid write operations (the public/ directory
// resolved source directory to avoid write operations (the public/ directory
// is read-only under systemd ProtectSystem=strict on production).
// 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;
let sharedServePath = null;
(function resolveSharedAssets() {
(function resolveSharedAssets() {
@@ -308,7 +308,14 @@
@@ -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() {
function logout() {
+ serverLogout();
const activeId = getActiveId();
const activeId = getActiveId();
if (activeId) removeAccount(activeId);
if (activeId) removeAccount(activeId);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
@@ -319,6 +326,7 @@
@@ -319,6 +326,7 @@
}
}
function logoutAll() {
function logoutAll() {
+ serverLogout();
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACTIVE_KEY);
localStorage.removeItem(ACTIVE_KEY);
@@ -38,6 +38,10 @@
@@ -38,6 +38,10 @@
// but this browser has signed in to the network before (ov_sso_hint), try one silent
// 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.
// prompt=none round trip per tab so a session on one site becomes a session on all.
silentLogin: null,
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: [] };
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
let _runtimeLinks = null;
@@ -790,15 +794,84 @@
@@ -790,15 +794,84 @@
* never for bots. The site's login route turns silent=1 into prompt=none and comes straight
* 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.
* 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() {
function maybeSilentLogin() {
if (!_config.silentLogin || typeof location === 'undefined') return false;
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;
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;
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() {
function recordHistory() {
@@ -827,6 +900,7 @@
@@ -827,6 +900,7 @@
if (session.token) _config.token = session.token;
if (session.token) _config.token = session.token;
render();
render();
recordHistory();
recordHistory();
+ enableHandoff();
} else {
} else {
maybeSilentLogin();
maybeSilentLogin();
}
}
@@ -1019,6 +1093,8 @@
@@ -1019,6 +1093,8 @@
clearAuthState();
clearAuthState();
_config.user = null;
_config.user = null;
_config.token = null;
_config.token = null;
+ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (onToolsDomain()) {
if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
// and the httpOnly refresh cookie, then sends us back here.
@@ -1060,7 +1136,7 @@
@@ -1060,7 +1136,7 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
if (!_config.user) refreshAuthState();
- else recordHistory();
+ else { recordHistory(); enableHandoff(); }
return el;
return el;
},
},
@@ -1093,6 +1169,9 @@
@@ -1093,6 +1169,9 @@
if (_navEl && _config.user) render();
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 }). */
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
brand() { return resolveBrand(); },
brand() { return resolveBrand(); },
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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);