openvibers/openvibe.live Commits
Fix silent sign-in on first visits (Live) (#697)
Changes
@@@ -626,20 +626,23 @@ function _recordHistory(pageId) {document.head.appendChild(sc);}, 1800);}++/** This page was opened from another OpenVibe site (its referrer is first-party, and not Live itself). */++function _fromOpenVibe() {++ try { const r = new URL(document.referrer); return r.protocol === 'https:' && r.hostname !== location.hostname && /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/.test(r.hostname); } catch { return false; }++}/** Try to sign back in through openvibe.network without a chooser — once per tab session. */function _trySilentSso(force) {-- if (!force && _ssoHint() !== 'account') return false;++ if (currentUser || _ssoHint() === 'guest' || /bot|crawl|spider|headless/i.test(navigator.userAgent)) return false;++ // A reason to think there is a session: the quiet check said yes (force), Live signed them in before, or they came++ // from another OpenVibe site. Otherwise no redirect (a newcomer from a search engine).++ if (!force && _ssoHint() !== 'account' && !_fromOpenVibe()) return false;++ if (/[?&]sso=none\b/.test(location.search)) return false;try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }if (location.pathname.startsWith('/banned') || location.pathname.startsWith('/api/')) return false;// Come back to this exact page once the network has answered.-- location.href = '/api/auth/sso/login?silent=1&next=' + encodeURIComponent(location.pathname + location.search);++ location.replace('/api/auth/sso/login?silent=1&next=' + encodeURIComponent(location.pathname + location.search + location.hash));return true;}--/**-- * A guest with no hint yet: ask openvibe.network in a hidden iframe whether this browser is-- * 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);@@@ -655,7 +658,20 @@ function _loadSsoClient() {function _enableHandoff() {_loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!currentUser }); } catch { /* */ } });}++// Network's session must be read in a top-level navigation: an iframe cannot see it when++// third-party cookies are blocked. The existing SDK OAuth route uses prompt=none.++/**++ * Signed in to the network but not here. The top-level round trip (/api/auth/sso/login?silent=1 → Network prompt=none)++ * sees Network's session even where third-party cookies are blocked (phones), but costs a redirect, so it runs when there++ * is a reason to think the person has one: Live signed them in before ('account'), or they came from another OpenVibe++ * site (the referrer). A newcomer from anywhere else keeps the quiet iframe check and FedCM: no redirect on a first visit.++ */function _checkNetworkSession() {++ if (_ssoHint() === 'account' || _fromOpenVibe()) return _trySilentSso();++ return _quietNetworkCheck();++}++/** A guest with no hint and no OpenVibe referrer: ask openvibe.network from a hidden iframe (a yes signs in; FedCM asks the browser otherwise). */++function _quietNetworkCheck() {if (_ssoHint() === 'guest' || /bot|crawl|spider|headless/i.test(navigator.userAgent)) return;try {const last = +sessionStorage.getItem('ov_sso_check_at') || 0;@@@ -23,7 +23,7 @@ const { v4: uuidv4 } = require('uuid');const multer = require('multer');const fs = require('fs');const db = require('../db/database');--const { createSsoClient, failurePage } = require('openvibe-sdk/sso');++const { createSsoClient, failurePage, withParam } = require('openvibe-sdk/sso');const { requireAuth, optionalAuth } = require('./auth');const permissions = require('./permissions');const config = require('../config');@@@ -507,8 +507,7 @@ router.get('/callback', async (req, res) => {if (!code && req.query.error) {// Silent sign-in found no network session — go on quietly as a guest.clearFlowCookies(res);-- const sep = next.includes('?') ? '&' : '?';-- return res.redirect(`${next}${sep}sso=none`);++ return res.redirect(withParam(next, 'sso', 'none'));}if (!code) return res.status(400).send('Missing authorization code');nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs');++const vm = require('node:vm');++const source = fs.readFileSync(require('node:path').join(__dirname, '../public/js/app.js'), 'utf8');++const start = source.indexOf('function _fromOpenVibe(');++const end = source.indexOf('let _ssoClientPromise', start);++for (const scenario of [++ { name: 'first visit from another OpenVibe site', hint: null, referrer: 'https://openvibe.network/', expected: true },++ { name: 'first visit from a search engine (no redirect: the quiet check runs instead)', hint: null, referrer: 'https://www.google.com/' },++ { name: 'first visit typed in', hint: null },++ { name: 'a look-alike referrer', hint: null, referrer: 'https://openvibe.network.evil.example/' },++ { name: 'the quiet check said yes', hint: null, force: true, expected: true },++ { name: 'returning person', hint: 'account', expected: true },++ { name: 'explicit sign-out', hint: 'guest' },++ { name: 'guest callback', search: '?sso=none' },++ { name: 'already signed in', currentUser: { id: 1 } },++ { name: 'crawler', userAgent: 'Googlebot' },++ { name: 'blocked tab storage', blocked: true },++ { name: 'banned page', pathname: '/banned' },++ { name: 'API page', pathname: '/api/auth' },++]) {++ const tab = new Map(), redirects = [];++ const context = vm.createContext({++ currentUser: scenario.currentUser || null,++ _ssoHint: () => scenario.hint,++ navigator: { userAgent: scenario.userAgent || 'Chrome' },++ document: { referrer: scenario.referrer || '' },++ URL,++ sessionStorage: { getItem(k) { if (scenario.blocked) throw Error('blocked'); return tab.get(k); }, setItem: (k,v) => tab.set(k,v) },++ location: { hostname: 'openvibe.live', pathname: scenario.pathname || '/channel/probe', search: scenario.search || '?tab=chat', hash: '#latest', replace: url => redirects.push(url) },++ });++ vm.runInContext(source.slice(start, end), context);++ assert.equal(vm.runInContext(`_trySilentSso(${scenario.force ? 'true' : ''})`, context), !!scenario.expected, scenario.name);++ assert.equal(vm.runInContext(`_trySilentSso(${scenario.force ? 'true' : ''})`, context), false, 'never repeat the round trip');++ if (scenario.expected) assert.deepEqual(redirects, ['/api/auth/sso/login?silent=1&next=%2Fchannel%2Fprobe%3Ftab%3Dchat%23latest']);++ else assert.equal(redirects.length, 0);++}++console.log('silent first visit: all checks passed');@@@ -39,6 +39,10 @@ console.warn = () => {};assert.strictEqual(u.searchParams.get('prompt'), 'none', 'silent stays silent');u = await go('');assert.strictEqual(u.searchParams.get('prompt'), null, 'a plain sign-in: the Network shows its chooser');++ const guestStart = await fetch(`http://127.0.0.1:${server.address().port}/api/auth/sso/login?silent=1&next=%2Fchannel%3Ftab%3Dchat%23latest`, { redirect: 'manual' });++ const guestCookie = guestStart.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');++ const guestBack = await fetch(`http://127.0.0.1:${server.address().port}/api/auth/callback?error=login_required`, { redirect: 'manual', headers: { Cookie: guestCookie } });++ assert.strictEqual(guestBack.headers.get('location'), '/channel?tab=chat&sso=none#latest', 'the quiet-guest marker precedes the fragment');server.close();// The menu's Switch account keeps this session until another account is chosen.
@@ -626,20 +626,23 @@ function _recordHistory(pageId) {
@@ -626,20 +626,23 @@ function _recordHistory(pageId) {
document.head.appendChild(sc);
document.head.appendChild(sc);
}, 1800);
}, 1800);
}
}
+/** This page was opened from another OpenVibe site (its referrer is first-party, and not Live itself). */
+function _fromOpenVibe() {
+ try { const r = new URL(document.referrer); return r.protocol === 'https:' && r.hostname !== location.hostname && /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/.test(r.hostname); } catch { return false; }
+}
/** Try to sign back in through openvibe.network without a chooser — once per tab session. */
/** Try to sign back in through openvibe.network without a chooser — once per tab session. */
function _trySilentSso(force) {
function _trySilentSso(force) {
- if (!force && _ssoHint() !== 'account') return false;
+ if (currentUser || _ssoHint() === 'guest' || /bot|crawl|spider|headless/i.test(navigator.userAgent)) return false;
+ // A reason to think there is a session: the quiet check said yes (force), Live signed them in before, or they came
+ // from another OpenVibe site. Otherwise no redirect (a newcomer from a search engine).
+ if (!force && _ssoHint() !== 'account' && !_fromOpenVibe()) return false;
+ if (/[?&]sso=none\b/.test(location.search)) return false;
try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
if (location.pathname.startsWith('/banned') || location.pathname.startsWith('/api/')) return false;
if (location.pathname.startsWith('/banned') || location.pathname.startsWith('/api/')) return false;
// Come back to this exact page once the network has answered.
// Come back to this exact page once the network has answered.
- location.href = '/api/auth/sso/login?silent=1&next=' + encodeURIComponent(location.pathname + location.search);
+ location.replace('/api/auth/sso/login?silent=1&next=' + encodeURIComponent(location.pathname + location.search + location.hash));
return true;
return true;
}
}
-/**
- * A guest with no hint yet: ask openvibe.network in a hidden iframe whether this browser is
- * 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;
let _ssoClientPromise = null;
function _loadSsoClient() {
function _loadSsoClient() {
if (window.OpenVibeSSO) return Promise.resolve(window.OpenVibeSSO);
if (window.OpenVibeSSO) return Promise.resolve(window.OpenVibeSSO);
@@ -655,7 +658,20 @@ function _loadSsoClient() {
@@ -655,7 +658,20 @@ function _loadSsoClient() {
function _enableHandoff() {
function _enableHandoff() {
_loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!currentUser }); } catch { /* */ } });
_loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!currentUser }); } catch { /* */ } });
}
}
+// Network's session must be read in a top-level navigation: an iframe cannot see it when
+// third-party cookies are blocked. The existing SDK OAuth route uses prompt=none.
+/**
+ * Signed in to the network but not here. The top-level round trip (/api/auth/sso/login?silent=1 → Network prompt=none)
+ * sees Network's session even where third-party cookies are blocked (phones), but costs a redirect, so it runs when there
+ * is a reason to think the person has one: Live signed them in before ('account'), or they came from another OpenVibe
+ * site (the referrer). A newcomer from anywhere else keeps the quiet iframe check and FedCM: no redirect on a first visit.
+ */
function _checkNetworkSession() {
function _checkNetworkSession() {
+ if (_ssoHint() === 'account' || _fromOpenVibe()) return _trySilentSso();
+ return _quietNetworkCheck();
+}
+/** A guest with no hint and no OpenVibe referrer: ask openvibe.network from a hidden iframe (a yes signs in; FedCM asks the browser otherwise). */
+function _quietNetworkCheck() {
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 {
const last = +sessionStorage.getItem('ov_sso_check_at') || 0;
const last = +sessionStorage.getItem('ov_sso_check_at') || 0;
@@ -23,7 +23,7 @@ const { v4: uuidv4 } = require('uuid');
@@ -23,7 +23,7 @@ const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const multer = require('multer');
const fs = require('fs');
const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
-const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
+const { createSsoClient, failurePage, withParam } = require('openvibe-sdk/sso');
const { requireAuth, optionalAuth } = require('./auth');
const { requireAuth, optionalAuth } = require('./auth');
const permissions = require('./permissions');
const permissions = require('./permissions');
const config = require('../config');
const config = require('../config');
@@ -507,8 +507,7 @@ router.get('/callback', async (req, res) => {
@@ -507,8 +507,7 @@ router.get('/callback', async (req, res) => {
if (!code && req.query.error) {
if (!code && req.query.error) {
// Silent sign-in found no network session — go on quietly as a guest.
// Silent sign-in found no network session — go on quietly as a guest.
clearFlowCookies(res);
clearFlowCookies(res);
- const sep = next.includes('?') ? '&' : '?';
- return res.redirect(`${next}${sep}sso=none`);
+ return res.redirect(withParam(next, 'sso', 'none'));
}
}
if (!code) return res.status(400).send('Missing authorization code');
if (!code) return res.status(400).send('Missing authorization code');
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const source = fs.readFileSync(require('node:path').join(__dirname, '../public/js/app.js'), 'utf8');
+const start = source.indexOf('function _fromOpenVibe(');
+const end = source.indexOf('let _ssoClientPromise', start);
+for (const scenario of [
+ { name: 'first visit from another OpenVibe site', hint: null, referrer: 'https://openvibe.network/', expected: true },
+ { name: 'first visit from a search engine (no redirect: the quiet check runs instead)', hint: null, referrer: 'https://www.google.com/' },
+ { name: 'first visit typed in', hint: null },
+ { name: 'a look-alike referrer', hint: null, referrer: 'https://openvibe.network.evil.example/' },
+ { name: 'the quiet check said yes', hint: null, force: true, expected: true },
+ { name: 'returning person', hint: 'account', expected: true },
+ { name: 'explicit sign-out', hint: 'guest' },
+ { name: 'guest callback', search: '?sso=none' },
+ { name: 'already signed in', currentUser: { id: 1 } },
+ { name: 'crawler', userAgent: 'Googlebot' },
+ { name: 'blocked tab storage', blocked: true },
+ { name: 'banned page', pathname: '/banned' },
+ { name: 'API page', pathname: '/api/auth' },
+]) {
+ const tab = new Map(), redirects = [];
+ const context = vm.createContext({
+ currentUser: scenario.currentUser || null,
+ _ssoHint: () => scenario.hint,
+ navigator: { userAgent: scenario.userAgent || 'Chrome' },
+ document: { referrer: scenario.referrer || '' },
+ URL,
+ sessionStorage: { getItem(k) { if (scenario.blocked) throw Error('blocked'); return tab.get(k); }, setItem: (k,v) => tab.set(k,v) },
+ location: { hostname: 'openvibe.live', pathname: scenario.pathname || '/channel/probe', search: scenario.search || '?tab=chat', hash: '#latest', replace: url => redirects.push(url) },
+ });
+ vm.runInContext(source.slice(start, end), context);
+ assert.equal(vm.runInContext(`_trySilentSso(${scenario.force ? 'true' : ''})`, context), !!scenario.expected, scenario.name);
+ assert.equal(vm.runInContext(`_trySilentSso(${scenario.force ? 'true' : ''})`, context), false, 'never repeat the round trip');
+ if (scenario.expected) assert.deepEqual(redirects, ['/api/auth/sso/login?silent=1&next=%2Fchannel%2Fprobe%3Ftab%3Dchat%23latest']);
+ else assert.equal(redirects.length, 0);
+}
+console.log('silent first visit: all checks passed');
@@ -39,6 +39,10 @@ console.warn = () => {};
@@ -39,6 +39,10 @@ console.warn = () => {};
assert.strictEqual(u.searchParams.get('prompt'), 'none', 'silent stays silent');
assert.strictEqual(u.searchParams.get('prompt'), 'none', 'silent stays silent');
u = await go('');
u = await go('');
assert.strictEqual(u.searchParams.get('prompt'), null, 'a plain sign-in: the Network shows its chooser');
assert.strictEqual(u.searchParams.get('prompt'), null, 'a plain sign-in: the Network shows its chooser');
+ const guestStart = await fetch(`http://127.0.0.1:${server.address().port}/api/auth/sso/login?silent=1&next=%2Fchannel%3Ftab%3Dchat%23latest`, { redirect: 'manual' });
+ const guestCookie = guestStart.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
+ const guestBack = await fetch(`http://127.0.0.1:${server.address().port}/api/auth/callback?error=login_required`, { redirect: 'manual', headers: { Cookie: guestCookie } });
+ assert.strictEqual(guestBack.headers.get('location'), '/channel?tab=chat&sso=none#latest', 'the quiet-guest marker precedes the fragment');
server.close();
server.close();
// The menu's Switch account keeps this session until another account is chosen.
// The menu's Switch account keeps this session until another account is chosen.