openvibers/openvibe.live Commits

Fix silent sign-in on first visits (Live) (#697)

OP OpenVibers committed 5 days ago · 334bf4052d · 4 files, +68 −10

* Fix silent sign-in on first visits (live)

* Keep Live's silent guest marker before the URL fragment

* Silent sign-in on Live: the round trip only with a reason (Live's account hint, an OpenVibe referrer, or the quiet check's yes); a newcomer keeps the quiet check

Changes

ddiff --git a/public/js/app.js b/public/js/app.js
iindex 90a32aff..ca5f0ad1 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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;
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex d981b8f3..1d5e2efc 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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');
ddiff --git a/test/silent-first-visit.test.js b/test/silent-first-visit.test.js
nnew file mode 100644
iindex 00000000..b5f2e2fa
---- /dev/null
++++ b/test/silent-first-visit.test.js
@@@ -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');
ddiff --git a/test/sso-login-prompt.test.js b/test/sso-login-prompt.test.js
iindex a0c61f00..3d7327b9 100644
---- a/test/sso-login-prompt.test.js
++++ b/test/sso-login-prompt.test.js
@@@ -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.