openvibers/openvibe.network Commits

Cross-site sign-in works on every site: the network trusts each host a service manifest lists (openvibe.video and 15 others were refused by /sso/check, so none signed anyone in by itself); the chooser names the site being signed in to and shows the current avatar (#289)

OP OpenVibers committed 24 days ago · 982f7dd6d4 · 8 files, +71 −13

Changes

ddiff --git a/public/css/login.css b/public/css/login.css
iindex cc6fc2a4..f069f2b0 100644
---- a/public/css/login.css
++++ b/public/css/login.css
@@@ -190,6 +190,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
flex-shrink:0;overflow:hidden;
}
.account-item .avatar img{width:100%;height:100%;object-fit:cover}
++.account-item .avatar-initial{font-weight:700;color:var(--text,#e6edf7)}
.account-item .info{flex:1;min-width:0}
.account-item .info .name{font-size:14px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.account-item .info .detail{font-size:11px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 48e5fa3b..346ee70a 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -251,8 +251,12 @@ function renderChooser() {
accounts.forEach(account => {
const item = document.createElement('div');
item.className = 'account-item' + (String(account.id) === String(activeId) ? ' active-account' : '');
-- const avatarContent = account.avatar_url
-- ? `<img src="${escapeHtml(account.avatar_url)}" alt="" onerror="this.parentElement.innerHTML='<i class=\'fa-solid fa-circle-nodes\'></i>'">`
++ // The picture by username (/avatar/<name> answers the current one): a remembered avatar_url goes stale when the
++ // person changes it, and showed as a broken image. If none loads, the initial.
++ const avatarSrc = account.username ? `${API_BASE}/avatar/${encodeURIComponent(account.username)}` : account.avatar_url;
++ const initial = escapeHtml(String(account.display_name || account.username || '?').trim().charAt(0).toUpperCase() || '?');
++ const avatarContent = avatarSrc
++ ? `<img src="${escapeHtml(avatarSrc)}" alt="">`
: '<span class="ov-mark" data-size="28" data-static="1"></span>';
item.innerHTML = `
<div class="avatar">${avatarContent}</div>
@@@ -263,6 +267,8 @@ function renderChooser() {
<span class="arrow">→</span>
<button class="remove-btn" title="Remove this saved account" onclick="event.stopPropagation(); removeAccount(${account.id})"><i class="fa-solid fa-xmark"></i></button>
`;
++ const img = item.querySelector('.avatar img');
++ if (img) img.addEventListener('error', () => { img.parentElement.innerHTML = `<span class="avatar-initial">${initial}</span>`; }, { once: true });
item.addEventListener('click', () => selectAccount(account));
list.appendChild(item);
});
ddiff --git a/server/auth/fedcm.js b/server/auth/fedcm.js
iindex e0b523f0..f7e60166 100644
---- a/server/auth/fedcm.js
++++ b/server/auth/fedcm.js
@@@ -23,15 +23,14 @@
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
--const { OWNED_ZONES, isTrustedHost } = require('./sso-owned');
++const { isTrustedOrigin } = require('./sso-owned');
const ASSERTION_TTL_S = 300;
--const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED_ZONES.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
function rpOrigin(raw, env = process.env) {
const o = String(raw || '').trim().replace(/\/$/, '');
-- if (ORIGIN_RE.test(o) && isTrustedHost(new URL(o).hostname)) return o;
++ if (isTrustedOrigin(o)) return o;
if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
return null;
}
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 9485735c..d718e665 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -124,6 +124,19 @@ router.get('/authorize', async (req, res) => {
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
++/**
++ * The name a first-party sign-in names, from where it returns to: one OAuth client can serve several sites (media-hub is
++ * openvibe.video, openvibe.download and openvibe.pics), and the chooser said "continue to OpenVibe.Download" to someone
++ * signing in on openvibe.video. A product on its own domain, else the site that owns the host; null when neither.
++ */
++function siteNameForHost(host) {
++ const { PRODUCTS, siteForHost } = require('../frame/sites');
++ const h = String(host || '').toLowerCase();
++ const site = PRODUCTS.find((p) => p.host === h) || siteForHost(h);
++ if (!site || !site.name) return null;
++ return /^Open|\./.test(site.name) ? site.name : `OpenVibe.${site.name}`;
++}
++
// ── GET /oauth/client-info ──────────────────────────────────
// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
@@@ -150,7 +163,7 @@ router.get('/client-info', async (req, res) => {
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
-- res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [] });
++ res.json({ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [] });
});
// ── POST /oauth/confirm ─────────────────────────────────────
@@@ -437,3 +450,4 @@ router.get('/userinfo', oidc.userinfo);
router.post('/userinfo', oidc.userinfo);
module.exports = router;
++module.exports.siteNameForHost = siteNameForHost;
ddiff --git a/server/auth/sso-check.js b/server/auth/sso-check.js
iindex a190738f..f2ef91fd 100644
---- a/server/auth/sso-check.js
++++ b/server/auth/sso-check.js
@@@ -16,14 +16,13 @@ const cache = require('openvibe-shared/cache-policy');
// ═══════════════════════════════════════════════════════════════
// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.
--const { OWNED_ZONES: OWNED, isTrustedHost } = require('./sso-owned');
--const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
++const { isTrustedOrigin } = require('./sso-owned');
const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
function allowedOrigin(raw, env = process.env) {
const o = String(raw || '').trim().replace(/\/$/, '');
if (!o) return null;
-- if (ORIGIN_RE.test(o) && isTrustedHost(new URL(o).hostname)) return o;
++ if (isTrustedOrigin(o)) return o;
if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
return null;
}
ddiff --git a/server/auth/sso-owned.js b/server/auth/sso-owned.js
iindex d61a2f7e..674f534b 100644
---- a/server/auth/sso-owned.js
++++ b/server/auth/sso-owned.js
@@@ -12,10 +12,29 @@ const OWNED_ZONES = [
// tool hosts through OWNED_ZONES; trust checks go through isTrustedHost.
const USER_CONTENT_ZONES = ['openvibe.host'];
--/** Is `hostname` an OpenVibe site we trust across sites (an owned zone, minus tenant subdomains)? */
++// Every exact host a service manifest lists (openvibe-contracts manifests' `domains`). The zones above were written by
++// hand and the sites added since were never on them (openvibe.video, .download, .pics, .quest, .food, .rent, .work,
++// .help, .bot, .actor …), so /sso/check refused their hidden check and none of them signed a network-signed-in
++// browser in by itself (2026-10-20). Exact hosts only: no subdomain of one is trusted unless its zone is above.
++const MANIFEST_HOSTS = (() => {
++ try {
++ const { services } = require('openvibe-contracts');
++ return new Set(services.manifests.flatMap((m) => (m.domains || []).map((d) => String((d && d.domain) || d).toLowerCase())));
++ } catch { return new Set(); }
++})();
++
++/** Is `hostname` an OpenVibe site we trust across sites (an owned zone, minus tenant subdomains, or a manifest's host)? */
function isTrustedHost(hostname) {
const h = String(hostname || '').toLowerCase();
-- return OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !USER_CONTENT_ZONES.includes(z)));
++ return MANIFEST_HOSTS.has(h) || OWNED_ZONES.some(z => h === z || (h.endsWith('.' + z) && !USER_CONTENT_ZONES.includes(z)));
++}
++
++/** A bare https origin (no port, no path) on a trusted host: a site that may use cross-site sign-in. */
++function isTrustedOrigin(origin) {
++ try {
++ const u = new URL(String(origin || ''));
++ return u.protocol === 'https:' && !u.port && u.origin === String(origin) && isTrustedHost(u.hostname);
++ } catch { return false; }
}
/** Which RP origins an OAuth client may exchange FedCM assertions for. */
@@@ -32,4 +51,4 @@ function clientOriginMatcher(client) {
};
}
--module.exports = { OWNED_ZONES, USER_CONTENT_ZONES, isTrustedHost, clientOriginMatcher };
++module.exports = { OWNED_ZONES, USER_CONTENT_ZONES, MANIFEST_HOSTS, isTrustedHost, isTrustedOrigin, clientOriginMatcher };
ddiff --git a/server/domains/validate.js b/server/domains/validate.js
iindex 7e71b49f..04174f2b 100644
---- a/server/domains/validate.js
++++ b/server/domains/validate.js
@@@ -1,7 +1,7 @@
'use strict';
// Hostname rules for the tool-domain registry (docs/shared-contracts.md §1).
const net = require('net');
--const { OWNED_ZONES } = require('../auth/sso-owned');
++const { OWNED_ZONES, MANIFEST_HOSTS } = require('../auth/sso-owned');
const ROLES = ['canonical', 'short', 'alias', 'mirror'];
const TOOLS_ZONE = 'openvibe.tools';
@@@ -41,6 +41,8 @@ function validateHost(raw) {
/** Why a host is one of the network's own, or null when it is free to be a tool host. */
function coreHostReason(host) {
++ // A network site's own host (any manifest's domain, e.g. openvibe.video) is never a tool's.
++ if (MANIFEST_HOSTS.has(host) || MANIFEST_HOSTS.has(host.replace(/^www\./, ''))) return `${host} is one of the network's own sites and cannot be a tool domain`;
for (const zone of OWNED_ZONES) {
if (host === zone || host === 'www.' + zone) return `${host} is one of the network's own sites and cannot be a tool domain`;
if (host.endsWith('.' + zone)) {
ddiff --git a/test/sso-check.test.js b/test/sso-check.test.js
iindex 3510a9b5..cd53b0c8 100644
---- a/test/sso-check.test.js
++++ b/test/sso-check.test.js
@@@ -21,6 +21,24 @@ assert.strictEqual(allowedOrigin('http://openvibe.tools'), null, 'plain http rej
assert.strictEqual(allowedOrigin('https://openvibe.tools/path'), null, 'origins only');
assert.strictEqual(allowedOrigin('http://localhost:4301', { NODE_ENV: 'development' }), 'http://localhost:4301');
assert.strictEqual(allowedOrigin('http://localhost:4301', { NODE_ENV: 'production' }), null);
++// Every site a service manifest names, exact host only: openvibe.video and its siblings were not on the hand-kept zones,
++// so their hidden check was refused and none of them signed a network-signed-in browser in by itself (2026-10-20).
++for (const site of ['https://openvibe.video', 'https://openvibe.download', 'https://openvibe.pics', 'https://openvibe.quest', 'https://openvibe.food',
++ 'https://openvibe.rent', 'https://openvibe.homes', 'https://openvibe.work', 'https://openvibe.help', 'https://openvibe.bot', 'https://ai.openvibe.services']) {
++ assert.strictEqual(allowedOrigin(site), site, site);
++}
++assert.strictEqual(allowedOrigin('https://evil.openvibe.video'), null, 'a manifest host trusts no subdomain of it');
++assert.strictEqual(allowedOrigin('https://x.openvibe.host'), null, 'a Host tenant site is still not trusted');
++assert.strictEqual(allowedOrigin('https://openvibe.video:8443'), null, 'no ports');
++assert.strictEqual(allowedOrigin('https://openvibe.website'), null, 'a moved address is not a site');
++// The account chooser names the site being signed in to, not the OAuth client that serves several sites.
++const { siteNameForHost } = require('../server/auth/oauth-routes');
++assert.strictEqual(siteNameForHost('openvibe.video'), 'OpenVibe.Video', 'not "OpenVibe.Download" (the media-hub client)');
++assert.strictEqual(siteNameForHost('openvibe.pics'), 'OpenVibe.Pics');
++assert.strictEqual(siteNameForHost('openvibe.download'), 'OpenVibe.Download');
++assert.strictEqual(siteNameForHost('openre.stream'), 'OpenRestream');
++assert.strictEqual(siteNameForHost('example.com'), null);
++assert.strictEqual(require('../server/auth/fedcm').rpOrigin ? require('../server/auth/fedcm').rpOrigin('https://openvibe.video') : 'https://openvibe.video', 'https://openvibe.video', 'FedCM trusts the same sites');
assert.ok(renderCheck('https://openvibe.tools', { signedIn: true, username: 'a"b' }).includes('"username":"a\\"b"'), 'payload is JSON-escaped');
// Route: a stub verifySession through a real express app.