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

OP OpenVibers committed 18 Sept 2026 · cdabe8d9cb · 14 files, +237 −23

- ov_sso: the same session as ov_token but httpOnly + SameSite=None, so another OpenVibe site's
  hidden iframe (GET /sso/check?origin=…) can learn "signed in here / not" without a redirect;
  the answer is one postMessage addressed to the asking origin, framed only by that origin
  (frame-ancestors), never cached, and carries no token — the site still completes OAuth itself
- shared navbar: with no local session, hint 'account' → silent sign-in at once; otherwise ask
  /sso/check first and only a yes redirects (once per tab per 10 min, never after sign-out, never
  for bots) — guests never see a redirect; sites pick the session up when they are opened
- login no longer routes through /sso/fanout (only with ?everywhere=1); the chain stays behind the
  explicit "Sign in / out everywhere" buttons
- POST /api/auth/logout clears the server-held cookies; the account switcher and the fanout
  sign-out call it (ov_sso is httpOnly, JS cannot drop it)
- session.js: setSessionCookies/clearSessionCookies/requestToken used by login, register,
  refresh, renewal, OAuth confirm and prompt=none (which now also honours ov_sso)
- nginx: location = /sso/check without the server-level X-Frame-Options; conf.d proxy buffer
  snippet (32k) — sign-in callbacks carrying several JWT cookies 502'd on the 4k default
- tests: sso-check (origin allow-list, headers, cookie paths); e2e verified headlessly

Changes

ddiff --git a/deploy/nginx/conf.d/openvibe-proxy-buffers.conf b/deploy/nginx/conf.d/openvibe-proxy-buffers.conf
nnew file mode 100644
iindex 00000000..77034b24
---- /dev/null
++++ b/deploy/nginx/conf.d/openvibe-proxy-buffers.conf
@@@ -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;
ddiff --git a/deploy/nginx/openvibe.network.conf b/deploy/nginx/openvibe.network.conf
iindex 31ddf3e8..5be5bc50 100644
---- a/deploy/nginx/openvibe.network.conf
++++ b/deploy/nginx/openvibe.network.conf
@@@ -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;
ddiff --git a/docs/platform-plan.md b/docs/platform-plan.md
iindex 10ae002e..809cb48f 100644
---- a/docs/platform-plan.md
++++ b/docs/platform-plan.md
@@@ -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. |
ddiff --git a/package.json b/package.json
iindex d5762844..36657a37 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/packages/openvibe-shared/account-switcher.js b/packages/openvibe-shared/account-switcher.js
iindex cc16ff37..04e9dbc1 100644
---- a/packages/openvibe-shared/account-switcher.js
++++ b/packages/openvibe-shared/account-switcher.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);
ddiff --git a/packages/openvibe-shared/navbar.js b/packages/openvibe-shared/navbar.js
iindex 47afb984..977d960f 100644
---- a/packages/openvibe-shared/navbar.js
++++ b/packages/openvibe-shared/navbar.js
@@@ -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(); },
ddiff --git a/public/login.html b/public/login.html
iindex 957adcb8..05ce5b61 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -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));
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
iindex c033af2b..064c90d3 100644
---- a/public/sso-fanout.html
++++ b/public/sso-fanout.html
@@@ -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);
};
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 388f87a8..4c497500 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -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 || ''}` });
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 3618ca10..e4a497fb 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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 token
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({
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);
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex f3943f68..48df257d 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -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 token
res.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 };
ddiff --git a/server/auth/sso-check.js b/server/auth/sso-check.js
nnew file mode 100644
iindex 00000000..e796759f
---- /dev/null
++++ b/server/auth/sso-check.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex a85533e7..98a26f5e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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');
ddiff --git a/test/sso-check.test.js b/test/sso-check.test.js
nnew file mode 100644
iindex 00000000..751b101f
---- /dev/null
++++ b/test/sso-check.test.js
@@@ -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); });