openvibers/openvibe.network Commits
Lazy cross-site sessions: hidden /sso/check instead of the sign-in redirect chain; nginx buffers for the cookie-heavy callbacks
Changes
nnew file mode 100644@@@ -0,0 +1,6 @@++# Installed at /etc/nginx/conf.d/ on the host (applies to every OpenVibe vhost).++# Sign-in responses carry several JWT cookies (token, ov_token, refresh, hints); nginx's 4k++# default upstream header buffer answered them with "502 upstream sent too big header".++proxy_buffer_size 32k;++proxy_buffers 8 32k;++proxy_busy_buffers_size 64k;@@@ -65,6 +65,18 @@ server {}# ── Shared static assets (long cache, CORS *) ───────────++ # Framed by other OpenVibe sites (frame-ancestors is set by the app per request); a location++ # with its own add_header drops the server-level X-Frame-Options that would block that.++ location = /sso/check {++ add_header Cache-Control "no-store" always;++ proxy_pass http://127.0.0.1:4000;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++location /shared/ {proxy_pass http://127.0.0.1:4000;proxy_http_version 1.1;@@@ -38,7 +38,7 @@ into the Node services that need the server-side helpers.| System | Module | How a room uses it ||---|---|---|| **Identity / SSO** | OAuth2 in the Network (`/oauth/authorize`, `/oauth/token`, JWKS, `prompt=none`); `auth-client.js` + `middleware.js` server-side | Register a client, implement `/auth/login` (`?silent=1&next=`), `/auth/callback` (sets `ov_token` + `ov_sso_hint`), `/auth/logout?next=`, `/auth/me`. Live, Tools, Games and Community do this today. |--| **Sign in everywhere** | `/sso/fanout` + `server/auth/sso-targets.js` | After any network sign-in the browser hops through every room's silent login once; sign-out is the same chain. Add a room = add a target. The shared navbar's `silentLogin` option does the lazy version (hint-gated, once per tab). |++| **Session pick-up (lazy SSO)** | `GET /sso/check` (`server/auth/sso-check.js`) + the `ov_sso` cookie + the navbar's `silentLogin` option | Nothing happens at sign-in. When a room is opened without a session it asks the network in a hidden iframe ("is this browser signed in?"); only a yes triggers one silent `prompt=none` sign-in that lands back on the same page. Browsers that partition third-party cookies answer no and the room falls back to its own `ov_sso_hint` from an earlier sign-in. `/sso/fanout` (the redirect chain) remains as the explicit "Sign in / out everywhere" buttons on `/my#linked`. || **Navbar** | `navbar.js` — brand from the hostname (`Pastes · OpenVibe · Tools`), compact mode, `links`, `menu.before/after`, `addMenuItem`, account switcher, bell, "Recently used" | `OpenVibeNavbar.init({ service, links, menu, history, silentLogin })`. Nothing else. || **Footer** | `footer.js` — network column, legal, socials, signed-in row, `full`/`compact` | `OpenVibeFooter.init({ service, variant, links })`. || **Theme** | `theme-loader.js` + `builtin-themes.js` + `theme-sync.js` (generated loader map, derived tokens `--on-accent`, `--accent-rgb`, `--accent-glow`) | Load the loader first in `<head>`; style with the tokens. The user's theme follows them to every room. |@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js",++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -308,7 +308,14 @@}}++ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */++ function serverLogout() {++ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }++ }++function logout() {++ serverLogout();const activeId = getActiveId();if (activeId) removeAccount(activeId);localStorage.removeItem(TOKEN_KEY);@@@ -319,6 +326,7 @@}function logoutAll() {++ serverLogout();localStorage.removeItem(STORAGE_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(ACTIVE_KEY);@@@ -790,15 +790,56 @@* 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.*/++ 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) silentLoginNow(); });++ 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() {@@@ -1093,6 +1134,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(); },@@@ -732,6 +732,9 @@ function getReturnUrl() {// then on to where the user was going. An OAuth client that just got its code is skipped// (it is about to sign in with that code).function viaFanout(url, skipClient) {++ // Sites now pick the session up on their own (hidden /sso/check + silent sign-in when they++ // are opened), so the chain only runs when explicitly asked for.++ if (params.get('everywhere') !== '1') return url;if (!url || /[?&]error=/.test(String(url))) return url;const u = new URL('/sso/fanout', window.location.origin);u.searchParams.set('next', String(url));@@@ -73,6 +73,7 @@ li.now .dot i{animation:spin 1s linear infinite}// The network's own session goes last, after every site had its turn.try { for (const k of ['ov_token', 'openvibe_accounts', 'openvibe_active_account', 'openvibe_anon_token', 'openvibe_anon_tokens']) localStorage.removeItem(k); } catch (e) { /* */ }document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax;Secure';++ try { fetch('/api/auth/logout', { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch (e) { /* */ }}location.replace(next);};@@@ -44,11 +44,12 @@ router.get('/authorize', (req, res) => {if (String(req.query.prompt || '') === 'none') {const sep = redirect_uri.includes('?') ? '&' : '?';try {-- const { verifySession, COOKIE } = require('./session');-- const out = verifySession(req.cookies?.ov_token, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });++ const { verifySession, setSessionCookies } = require('./session');++ const have = req.cookies?.ov_token || req.cookies?.ov_sso;++ const out = verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });if (!out.error) {-- const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : req.cookies.ov_token;-- res.cookie('ov_token', token, COOKIE);++ const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;++ setSessionCookies(res, token);const code = crypto.randomBytes(32).toString('hex');const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?)`)@@@ -118,7 +119,7 @@ router.post('/confirm', (req, res) => {// Also set cookie to this account so openvibe.network itself knows the active session.// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.-- res.cookie('ov_token', token, { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' });++ require('./session').setSessionCookies(res, token);const sep = redirect_uri.includes('?') ? '&' : '?';res.json({ redirect: `${redirect_uri}${sep}code=${code}&state=${state || ''}` });@@@ -198,6 +198,7 @@ router.post('/login', (req, res) => {const token = signToken(user, req.app.locals.privateKey, config);console.log(`[Auth] Login: ${user.username}`);++ require('./session').setSessionCookies(res, token);res.json({ token, user: sanitizeUser(user) });});@@@ -347,14 +348,8 @@ router.post('/refresh', (req, res) => {// Issue fresh tokenconst newToken = signToken(user, req.app.locals.privateKey, config);-- // Update session cookie — host-only on openvibe.network (NO Domain attribute)-- res.cookie('ov_token', newToken, {-- httpOnly: false,-- maxAge: 90 * 24 * 60 * 60 * 1000,-- sameSite: 'Lax',-- secure: true,-- path: '/',-- });++ // Update session cookies — host-only on openvibe.network (NO Domain attribute)++ require('./session').setSessionCookies(res, newToken);res.json({token: newToken,@@@ -501,6 +496,14 @@ function logAnonIp(db, anonId, ip) {// POST /api/auth/anon-session// Creates a temporary anonymous identity with a unique number.// If force_new=true, always creates a new anon identity.++// Server-side sign-out: the page-side storage is cleared by the caller; this drops the cookies++// only the server can (ov_sso is httpOnly). Idempotent, no auth required.++router.post('/logout', (req, res) => {++ require('./session').clearSessionCookies(res);++ res.set('Cache-Control', 'no-store');++ res.json({ ok: true });++});++router.post('/anon-session', (req, res) => {const db = getDb(req);const config = getConfig(req);@@@ -42,11 +42,31 @@ function verifySession(token, { db, publicKey, config }) {}const COOKIE = { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' };++// The same session for cross-site use: httpOnly and SameSite=None so a hidden <iframe> from++// another OpenVibe site (GET /sso/check) can see whether this browser is signed in here without++// any redirect. JS never reads it; prompt=none and /sso/check fall back to it when ov_token is++// not sent (third-party context). Browsers that partition third-party cookies simply answer++// "not signed in" and the site falls back to its own hint.++const SSO_COOKIE = { httpOnly: true, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'None', secure: true, path: '/' };++function setSessionCookies(res, token) {++ res.cookie('ov_token', token, COOKIE);++ res.cookie('ov_sso', token, SSO_COOKIE);++}++function clearSessionCookies(res) {++ res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });++ res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });++}++/** The session token a request carries: Bearer header, then the page cookie, then the cross-site one. */++function requestToken(req) {++ const h = req.headers?.authorization;++ if (h && h.startsWith('Bearer ')) return h.slice(7);++ return req.cookies?.ov_token || req.cookies?.ov_sso || null;++}/** Express guard factory: attaches req.user / req.token; slides the session when due. */function makeRequireAuth(getCtx, signToken) {return function requireAuth(req, res, next) {const authHeader = req.headers.authorization;-- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;++ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : (req.cookies?.ov_token || req.cookies?.ov_sso);const ctx = getCtx(req);const out = verifySession(token, ctx);if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });@@@ -56,7 +76,7 @@ function makeRequireAuth(getCtx, signToken) {try {const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config);req.token = fresh;-- res.cookie('ov_token', fresh, COOKIE);++ setSessionCookies(res, fresh);res.set('X-OV-Token', fresh); // JS clients swap their stored tokenres.set('Access-Control-Expose-Headers', 'X-OV-Token');} catch { /* renewal is best-effort */ }@@@ -65,4 +85,4 @@ function makeRequireAuth(getCtx, signToken) {};}--module.exports = { verifySession, makeRequireAuth, GRACE_MS, COOKIE };++module.exports = { verifySession, makeRequireAuth, GRACE_MS, COOKIE, SSO_COOKIE, setSessionCookies, clearSessionCookies, requestToken };nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// GET /sso/check?origin=https://openvibe.tools — the silent, invisible session check.++//++// A site that has no session of its own loads this page in a hidden <iframe>. It answers with one++// postMessage to the embedding site: { type: 'ov-sso', signedIn, username } — nothing more. If++// the browser is signed in here, the site then does its normal prompt=none sign-in (one quick++// redirect that comes straight back); if not, nothing visible happens at all. This replaces the++// "hop through every site after login" chain: sites pick the session up when they are opened.++//++// Safety: the target origin must be an OpenVibe origin (the message is addressed to it, so no++// other page can read it), the response may only be framed by that origin (frame-ancestors),++// it is never cached, and it carries no token — the site still has to complete OAuth itself.++// Browsers that partition third-party cookies see no ov_sso cookie and get signedIn: false.++// ═══════════════════════════════════════════════════════════════++++const ORIGIN_RE = /^https:\/\/(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/i;++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)) return o;++ if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;++ return null;++}++++function renderCheck(origin, state) {++ const payload = JSON.stringify({ type: 'ov-sso', signedIn: !!state.signedIn, username: state.username || null, at: Date.now() });++ return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>OpenVibe session</title></head><body><script>++(function(){try{window.parent.postMessage(${payload}, ${JSON.stringify(origin)});}catch(e){}})();++</script></body></html>`;++}++++function createSsoCheckRoute(getCtx) {++ return function ssoCheck(req, res) {++ const origin = allowedOrigin(req.query.origin);++ if (!origin) return res.status(400).type('text').send('origin must be an OpenVibe origin');++ const { verifySession, requestToken } = require('./session');++ let signedIn = false, username = null;++ try {++ const out = verifySession(requestToken(req), getCtx(req));++ if (!out.error && out.user && !out.user.is_anon) { signedIn = true; username = out.user.username || null; }++ } catch { /* not signed in */ }++ res.removeHeader('X-Frame-Options');++ res.setHeader('Content-Security-Policy', `frame-ancestors ${origin}; default-src 'none'; script-src 'unsafe-inline'`);++ res.setHeader('Cache-Control', 'no-store, private');++ res.setHeader('Vary', 'Cookie');++ res.setHeader('X-Robots-Tag', 'noindex');++ res.type('html').send(renderCheck(origin, { signedIn, username }));++ };++}++++module.exports = { createSsoCheckRoute, allowedOrigin, renderCheck };@@@ -538,6 +538,9 @@ app.get('/verify-email', (req, res) => {res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));});++// Silent cross-site session check (hidden iframe from any OpenVibe site) — see server/auth/sso-check.js++app.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ db, publicKey, config })));++// Sign-in / sign-out everywhere: the redirect chain through every first-party site.app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {res.setHeader('Cache-Control', 'no-store');nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++// GET /sso/check — origin allow-list, headers that let exactly one OpenVibe origin frame it,++// and the postMessage payload for signed-in / signed-out browsers.++// node test/sso-check.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const { allowedOrigin, renderCheck, createSsoCheckRoute } = require('../server/auth/sso-check');++++assert.strictEqual(allowedOrigin('https://openvibe.tools'), 'https://openvibe.tools');++assert.strictEqual(allowedOrigin('https://json.openvibe.tools/'), 'https://json.openvibe.tools');++assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.openvibe.games');++assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');++assert.strictEqual(allowedOrigin('https://openvibe.tools.evil.com'), null, 'suffix spoof rejected');++assert.strictEqual(allowedOrigin('https://evilopenvibe.tools'), null);++assert.strictEqual(allowedOrigin('http://openvibe.tools'), null, 'plain http rejected');++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);++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.++const app = express();++app.use(cookieParser());++// The route reads the session through server/auth/session.js; give it a key it can verify with.++const jwt = require('jsonwebtoken');++const secret = 'test-secret';++const config = { jwt: { issuer: 'https://openvibe.network', expiresIn: '1h' } };++const db = { prepare: () => ({ get: (id) => ({ id, username: 'goosely', is_banned: 0, token_valid_after: null }) }) };++app.get('/sso/check', createSsoCheckRoute(() => ({ db, publicKey: secret, config })));++const server = http.createServer(app);++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = (q, cookie) => fetch(`${base}/sso/check${q}`, { headers: cookie ? { cookie } : {} }).then(async r => ({ status: r.status, headers: r.headers, body: await r.text() }));++++ let r = await get('?origin=https://phish.example');++ assert.strictEqual(r.status, 400);++++ r = await get('?origin=https://openvibe.tools');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-security-policy'), "frame-ancestors https://openvibe.tools; default-src 'none'; script-src 'unsafe-inline'");++ assert.ok(!r.headers.get('x-frame-options'), 'no X-Frame-Options (the frame-ancestors directive is the rule)');++ assert.ok(/no-store/.test(r.headers.get('cache-control')));++ assert.ok(r.body.includes('"signedIn":false') && r.body.includes('"https://openvibe.tools"'), 'signed-out answer addressed to the origin');++++ const token = jwt.sign({ sub: 7, id: 7, username: 'goosely' }, secret, { issuer: config.jwt.issuer, expiresIn: '1h' });++ r = await get('?origin=https://openvibe.community', `ov_sso=${token}`);++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.includes('"signedIn":true') && r.body.includes('"username":"goosely"'), 'the httpOnly cross-site cookie alone is enough');++ r = await get('?origin=https://openvibe.community', `ov_token=${token}`);++ assert.ok(r.body.includes('"signedIn":true'), 'the page cookie works too');++ r = await get('?origin=https://openvibe.community', `ov_sso=not-a-jwt`);++ assert.ok(r.body.includes('"signedIn":false'), 'garbage cookie → signed out, still 200');++++ server.close();++ console.log('sso check: all checks passed');++})().catch((err) => { console.error(err); server.close(); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+# Installed at /etc/nginx/conf.d/ on the host (applies to every OpenVibe vhost).
+# Sign-in responses carry several JWT cookies (token, ov_token, refresh, hints); nginx's 4k
+# default upstream header buffer answered them with "502 upstream sent too big header".
+proxy_buffer_size 32k;
+proxy_buffers 8 32k;
+proxy_busy_buffers_size 64k;
@@ -65,6 +65,18 @@ server {
@@ -65,6 +65,18 @@ server {
}
}
# ── Shared static assets (long cache, CORS *) ───────────
# ── Shared static assets (long cache, CORS *) ───────────
+ # Framed by other OpenVibe sites (frame-ancestors is set by the app per request); a location
+ # with its own add_header drops the server-level X-Frame-Options that would block that.
+ location = /sso/check {
+ add_header Cache-Control "no-store" always;
+ proxy_pass http://127.0.0.1:4000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location /shared/ {
location /shared/ {
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_http_version 1.1;
@@ -38,7 +38,7 @@ into the Node services that need the server-side helpers.
@@ -38,7 +38,7 @@ into the Node services that need the server-side helpers.
| System | Module | How a room uses it |
| System | Module | How a room uses it |
|---|---|---|
|---|---|---|
| **Identity / SSO** | OAuth2 in the Network (`/oauth/authorize`, `/oauth/token`, JWKS, `prompt=none`); `auth-client.js` + `middleware.js` server-side | Register a client, implement `/auth/login` (`?silent=1&next=`), `/auth/callback` (sets `ov_token` + `ov_sso_hint`), `/auth/logout?next=`, `/auth/me`. Live, Tools, Games and Community do this today. |
| **Identity / SSO** | OAuth2 in the Network (`/oauth/authorize`, `/oauth/token`, JWKS, `prompt=none`); `auth-client.js` + `middleware.js` server-side | Register a client, implement `/auth/login` (`?silent=1&next=`), `/auth/callback` (sets `ov_token` + `ov_sso_hint`), `/auth/logout?next=`, `/auth/me`. Live, Tools, Games and Community do this today. |
-| **Sign in everywhere** | `/sso/fanout` + `server/auth/sso-targets.js` | After any network sign-in the browser hops through every room's silent login once; sign-out is the same chain. Add a room = add a target. The shared navbar's `silentLogin` option does the lazy version (hint-gated, once per tab). |
+| **Session pick-up (lazy SSO)** | `GET /sso/check` (`server/auth/sso-check.js`) + the `ov_sso` cookie + the navbar's `silentLogin` option | Nothing happens at sign-in. When a room is opened without a session it asks the network in a hidden iframe ("is this browser signed in?"); only a yes triggers one silent `prompt=none` sign-in that lands back on the same page. Browsers that partition third-party cookies answer no and the room falls back to its own `ov_sso_hint` from an earlier sign-in. `/sso/fanout` (the redirect chain) remains as the explicit "Sign in / out everywhere" buttons on `/my#linked`. |
| **Navbar** | `navbar.js` — brand from the hostname (`Pastes · OpenVibe · Tools`), compact mode, `links`, `menu.before/after`, `addMenuItem`, account switcher, bell, "Recently used" | `OpenVibeNavbar.init({ service, links, menu, history, silentLogin })`. Nothing else. |
| **Navbar** | `navbar.js` — brand from the hostname (`Pastes · OpenVibe · Tools`), compact mode, `links`, `menu.before/after`, `addMenuItem`, account switcher, bell, "Recently used" | `OpenVibeNavbar.init({ service, links, menu, history, silentLogin })`. Nothing else. |
| **Footer** | `footer.js` — network column, legal, socials, signed-in row, `full`/`compact` | `OpenVibeFooter.init({ service, variant, links })`. |
| **Footer** | `footer.js` — network column, legal, socials, signed-in row, `full`/`compact` | `OpenVibeFooter.init({ service, variant, links })`. |
| **Theme** | `theme-loader.js` + `builtin-themes.js` + `theme-sync.js` (generated loader map, derived tokens `--on-accent`, `--accent-rgb`, `--accent-glow`) | Load the loader first in `<head>`; style with the tokens. The user's theme follows them to every room. |
| **Theme** | `theme-loader.js` + `builtin-themes.js` + `theme-sync.js` (generated loader map, derived tokens `--on-accent`, `--accent-rgb`, `--accent-glow`) | Load the loader first in `<head>`; style with the tokens. The user's theme follows them to every room. |
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js",
+ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -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);
@@ -790,15 +790,56 @@
@@ -790,15 +790,56 @@
* 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.
*/
*/
+ 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) silentLoginNow(); });
+ 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() {
@@ -1093,6 +1134,9 @@
@@ -1093,6 +1134,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(); },
@@ -732,6 +732,9 @@ function getReturnUrl() {
@@ -732,6 +732,9 @@ function getReturnUrl() {
// then on to where the user was going. An OAuth client that just got its code is skipped
// then on to where the user was going. An OAuth client that just got its code is skipped
// (it is about to sign in with that code).
// (it is about to sign in with that code).
function viaFanout(url, skipClient) {
function viaFanout(url, skipClient) {
+ // Sites now pick the session up on their own (hidden /sso/check + silent sign-in when they
+ // are opened), so the chain only runs when explicitly asked for.
+ if (params.get('everywhere') !== '1') return url;
if (!url || /[?&]error=/.test(String(url))) return url;
if (!url || /[?&]error=/.test(String(url))) return url;
const u = new URL('/sso/fanout', window.location.origin);
const u = new URL('/sso/fanout', window.location.origin);
u.searchParams.set('next', String(url));
u.searchParams.set('next', String(url));
@@ -73,6 +73,7 @@ li.now .dot i{animation:spin 1s linear infinite}
@@ -73,6 +73,7 @@ li.now .dot i{animation:spin 1s linear infinite}
// The network's own session goes last, after every site had its turn.
// The network's own session goes last, after every site had its turn.
try { for (const k of ['ov_token', 'openvibe_accounts', 'openvibe_active_account', 'openvibe_anon_token', 'openvibe_anon_tokens']) localStorage.removeItem(k); } catch (e) { /* */ }
try { for (const k of ['ov_token', 'openvibe_accounts', 'openvibe_active_account', 'openvibe_anon_token', 'openvibe_anon_tokens']) localStorage.removeItem(k); } catch (e) { /* */ }
document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax;Secure';
document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax;Secure';
+ try { fetch('/api/auth/logout', { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch (e) { /* */ }
}
}
location.replace(next);
location.replace(next);
};
};
@@ -44,11 +44,12 @@ router.get('/authorize', (req, res) => {
@@ -44,11 +44,12 @@ router.get('/authorize', (req, res) => {
if (String(req.query.prompt || '') === 'none') {
if (String(req.query.prompt || '') === 'none') {
const sep = redirect_uri.includes('?') ? '&' : '?';
const sep = redirect_uri.includes('?') ? '&' : '?';
try {
try {
- const { verifySession, COOKIE } = require('./session');
- const out = verifySession(req.cookies?.ov_token, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
+ const { verifySession, setSessionCookies } = require('./session');
+ const have = req.cookies?.ov_token || req.cookies?.ov_sso;
+ const out = verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
if (!out.error) {
if (!out.error) {
- const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : req.cookies.ov_token;
- res.cookie('ov_token', token, COOKIE);
+ const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
+ setSessionCookies(res, token);
const code = crypto.randomBytes(32).toString('hex');
const code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?)`)
db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?)`)
@@ -118,7 +119,7 @@ router.post('/confirm', (req, res) => {
@@ -118,7 +119,7 @@ router.post('/confirm', (req, res) => {
// Also set cookie to this account so openvibe.network itself knows the active session.
// Also set cookie to this account so openvibe.network itself knows the active session.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
- res.cookie('ov_token', token, { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' });
+ require('./session').setSessionCookies(res, token);
const sep = redirect_uri.includes('?') ? '&' : '?';
const sep = redirect_uri.includes('?') ? '&' : '?';
res.json({ redirect: `${redirect_uri}${sep}code=${code}&state=${state || ''}` });
res.json({ redirect: `${redirect_uri}${sep}code=${code}&state=${state || ''}` });
@@ -198,6 +198,7 @@ router.post('/login', (req, res) => {
@@ -198,6 +198,7 @@ router.post('/login', (req, res) => {
const token = signToken(user, req.app.locals.privateKey, config);
const token = signToken(user, req.app.locals.privateKey, config);
console.log(`[Auth] Login: ${user.username}`);
console.log(`[Auth] Login: ${user.username}`);
+ require('./session').setSessionCookies(res, token);
res.json({ token, user: sanitizeUser(user) });
res.json({ token, user: sanitizeUser(user) });
});
});
@@ -347,14 +348,8 @@ router.post('/refresh', (req, res) => {
@@ -347,14 +348,8 @@ router.post('/refresh', (req, res) => {
// Issue fresh token
// Issue fresh token
const newToken = signToken(user, req.app.locals.privateKey, config);
const newToken = signToken(user, req.app.locals.privateKey, config);
- // Update session cookie — host-only on openvibe.network (NO Domain attribute)
- res.cookie('ov_token', newToken, {
- httpOnly: false,
- maxAge: 90 * 24 * 60 * 60 * 1000,
- sameSite: 'Lax',
- secure: true,
- path: '/',
- });
+ // Update session cookies — host-only on openvibe.network (NO Domain attribute)
+ require('./session').setSessionCookies(res, newToken);
res.json({
res.json({
token: newToken,
token: newToken,
@@ -501,6 +496,14 @@ function logAnonIp(db, anonId, ip) {
@@ -501,6 +496,14 @@ function logAnonIp(db, anonId, ip) {
// POST /api/auth/anon-session
// POST /api/auth/anon-session
// Creates a temporary anonymous identity with a unique number.
// Creates a temporary anonymous identity with a unique number.
// If force_new=true, always creates a new anon identity.
// If force_new=true, always creates a new anon identity.
+// Server-side sign-out: the page-side storage is cleared by the caller; this drops the cookies
+// only the server can (ov_sso is httpOnly). Idempotent, no auth required.
+router.post('/logout', (req, res) => {
+ require('./session').clearSessionCookies(res);
+ res.set('Cache-Control', 'no-store');
+ res.json({ ok: true });
+});
+
router.post('/anon-session', (req, res) => {
router.post('/anon-session', (req, res) => {
const db = getDb(req);
const db = getDb(req);
const config = getConfig(req);
const config = getConfig(req);
@@ -42,11 +42,31 @@ function verifySession(token, { db, publicKey, config }) {
@@ -42,11 +42,31 @@ function verifySession(token, { db, publicKey, config }) {
}
}
const COOKIE = { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' };
const COOKIE = { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' };
+// The same session for cross-site use: httpOnly and SameSite=None so a hidden <iframe> from
+// another OpenVibe site (GET /sso/check) can see whether this browser is signed in here without
+// any redirect. JS never reads it; prompt=none and /sso/check fall back to it when ov_token is
+// not sent (third-party context). Browsers that partition third-party cookies simply answer
+// "not signed in" and the site falls back to its own hint.
+const SSO_COOKIE = { httpOnly: true, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'None', secure: true, path: '/' };
+function setSessionCookies(res, token) {
+ res.cookie('ov_token', token, COOKIE);
+ res.cookie('ov_sso', token, SSO_COOKIE);
+}
+function clearSessionCookies(res) {
+ res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });
+ res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });
+}
+/** The session token a request carries: Bearer header, then the page cookie, then the cross-site one. */
+function requestToken(req) {
+ const h = req.headers?.authorization;
+ if (h && h.startsWith('Bearer ')) return h.slice(7);
+ return req.cookies?.ov_token || req.cookies?.ov_sso || null;
+}
/** Express guard factory: attaches req.user / req.token; slides the session when due. */
/** Express guard factory: attaches req.user / req.token; slides the session when due. */
function makeRequireAuth(getCtx, signToken) {
function makeRequireAuth(getCtx, signToken) {
return function requireAuth(req, res, next) {
return function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : (req.cookies?.ov_token || req.cookies?.ov_sso);
const ctx = getCtx(req);
const ctx = getCtx(req);
const out = verifySession(token, ctx);
const out = verifySession(token, ctx);
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
@@ -56,7 +76,7 @@ function makeRequireAuth(getCtx, signToken) {
@@ -56,7 +76,7 @@ function makeRequireAuth(getCtx, signToken) {
try {
try {
const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config);
const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config);
req.token = fresh;
req.token = fresh;
- res.cookie('ov_token', fresh, COOKIE);
+ setSessionCookies(res, fresh);
res.set('X-OV-Token', fresh); // JS clients swap their stored token
res.set('X-OV-Token', fresh); // JS clients swap their stored token
res.set('Access-Control-Expose-Headers', 'X-OV-Token');
res.set('Access-Control-Expose-Headers', 'X-OV-Token');
} catch { /* renewal is best-effort */ }
} catch { /* renewal is best-effort */ }
@@ -65,4 +85,4 @@ function makeRequireAuth(getCtx, signToken) {
@@ -65,4 +85,4 @@ function makeRequireAuth(getCtx, signToken) {
};
};
}
}
-module.exports = { verifySession, makeRequireAuth, GRACE_MS, COOKIE };
+module.exports = { verifySession, makeRequireAuth, GRACE_MS, COOKIE, SSO_COOKIE, setSessionCookies, clearSessionCookies, requestToken };
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// GET /sso/check?origin=https://openvibe.tools — the silent, invisible session check.
+//
+// A site that has no session of its own loads this page in a hidden <iframe>. It answers with one
+// postMessage to the embedding site: { type: 'ov-sso', signedIn, username } — nothing more. If
+// the browser is signed in here, the site then does its normal prompt=none sign-in (one quick
+// redirect that comes straight back); if not, nothing visible happens at all. This replaces the
+// "hop through every site after login" chain: sites pick the session up when they are opened.
+//
+// Safety: the target origin must be an OpenVibe origin (the message is addressed to it, so no
+// other page can read it), the response may only be framed by that origin (frame-ancestors),
+// it is never cached, and it carries no token — the site still has to complete OAuth itself.
+// Browsers that partition third-party cookies see no ov_sso cookie and get signedIn: false.
+// ═══════════════════════════════════════════════════════════════
+
+const ORIGIN_RE = /^https:\/\/(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/i;
+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)) return o;
+ if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
+ return null;
+}
+
+function renderCheck(origin, state) {
+ const payload = JSON.stringify({ type: 'ov-sso', signedIn: !!state.signedIn, username: state.username || null, at: Date.now() });
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>OpenVibe session</title></head><body><script>
+(function(){try{window.parent.postMessage(${payload}, ${JSON.stringify(origin)});}catch(e){}})();
+</script></body></html>`;
+}
+
+function createSsoCheckRoute(getCtx) {
+ return function ssoCheck(req, res) {
+ const origin = allowedOrigin(req.query.origin);
+ if (!origin) return res.status(400).type('text').send('origin must be an OpenVibe origin');
+ const { verifySession, requestToken } = require('./session');
+ let signedIn = false, username = null;
+ try {
+ const out = verifySession(requestToken(req), getCtx(req));
+ if (!out.error && out.user && !out.user.is_anon) { signedIn = true; username = out.user.username || null; }
+ } catch { /* not signed in */ }
+ res.removeHeader('X-Frame-Options');
+ res.setHeader('Content-Security-Policy', `frame-ancestors ${origin}; default-src 'none'; script-src 'unsafe-inline'`);
+ res.setHeader('Cache-Control', 'no-store, private');
+ res.setHeader('Vary', 'Cookie');
+ res.setHeader('X-Robots-Tag', 'noindex');
+ res.type('html').send(renderCheck(origin, { signedIn, username }));
+ };
+}
+
+module.exports = { createSsoCheckRoute, allowedOrigin, renderCheck };
@@ -538,6 +538,9 @@ app.get('/verify-email', (req, res) => {
@@ -538,6 +538,9 @@ app.get('/verify-email', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));
res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));
});
});
+// Silent cross-site session check (hidden iframe from any OpenVibe site) — see server/auth/sso-check.js
+app.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ db, publicKey, config })));
+
// Sign-in / sign-out everywhere: the redirect chain through every first-party site.
// Sign-in / sign-out everywhere: the redirect chain through every first-party site.
app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {
app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+// GET /sso/check — origin allow-list, headers that let exactly one OpenVibe origin frame it,
+// and the postMessage payload for signed-in / signed-out browsers.
+// node test/sso-check.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const { allowedOrigin, renderCheck, createSsoCheckRoute } = require('../server/auth/sso-check');
+
+assert.strictEqual(allowedOrigin('https://openvibe.tools'), 'https://openvibe.tools');
+assert.strictEqual(allowedOrigin('https://json.openvibe.tools/'), 'https://json.openvibe.tools');
+assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.openvibe.games');
+assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');
+assert.strictEqual(allowedOrigin('https://openvibe.tools.evil.com'), null, 'suffix spoof rejected');
+assert.strictEqual(allowedOrigin('https://evilopenvibe.tools'), null);
+assert.strictEqual(allowedOrigin('http://openvibe.tools'), null, 'plain http rejected');
+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);
+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.
+const app = express();
+app.use(cookieParser());
+// The route reads the session through server/auth/session.js; give it a key it can verify with.
+const jwt = require('jsonwebtoken');
+const secret = 'test-secret';
+const config = { jwt: { issuer: 'https://openvibe.network', expiresIn: '1h' } };
+const db = { prepare: () => ({ get: (id) => ({ id, username: 'goosely', is_banned: 0, token_valid_after: null }) }) };
+app.get('/sso/check', createSsoCheckRoute(() => ({ db, publicKey: secret, config })));
+const server = http.createServer(app);
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = (q, cookie) => fetch(`${base}/sso/check${q}`, { headers: cookie ? { cookie } : {} }).then(async r => ({ status: r.status, headers: r.headers, body: await r.text() }));
+
+ let r = await get('?origin=https://phish.example');
+ assert.strictEqual(r.status, 400);
+
+ r = await get('?origin=https://openvibe.tools');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-security-policy'), "frame-ancestors https://openvibe.tools; default-src 'none'; script-src 'unsafe-inline'");
+ assert.ok(!r.headers.get('x-frame-options'), 'no X-Frame-Options (the frame-ancestors directive is the rule)');
+ assert.ok(/no-store/.test(r.headers.get('cache-control')));
+ assert.ok(r.body.includes('"signedIn":false') && r.body.includes('"https://openvibe.tools"'), 'signed-out answer addressed to the origin');
+
+ const token = jwt.sign({ sub: 7, id: 7, username: 'goosely' }, secret, { issuer: config.jwt.issuer, expiresIn: '1h' });
+ r = await get('?origin=https://openvibe.community', `ov_sso=${token}`);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('"signedIn":true') && r.body.includes('"username":"goosely"'), 'the httpOnly cross-site cookie alone is enough');
+ r = await get('?origin=https://openvibe.community', `ov_token=${token}`);
+ assert.ok(r.body.includes('"signedIn":true'), 'the page cookie works too');
+ r = await get('?origin=https://openvibe.community', `ov_sso=not-a-jwt`);
+ assert.ok(r.body.includes('"signedIn":false'), 'garbage cookie → signed out, still 200');
+
+ server.close();
+ console.log('sso check: all checks passed');
+})().catch((err) => { console.error(err); server.close(); process.exit(1); });