openvibers/openvibe.network Commits

Sliding sessions: renewable tokens (60-day grace) are accepted and renewed by every auth guard (cookie + X-OV-Token), 7-day access tokens, 90-day cookies; OAuth prompt=none for silent app sign-in; stale saved accounts are prefilled instead of deleted; hub retries a refresh before bouncing to login; OV brand mark on login + home

OP OpenVibers committed 16 Sept 2026 · e52bee488f · 10 files, +209 −80

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/assets/ov-mark.js b/public/assets/ov-mark.js
nnew file mode 100644
iindex 00000000..2c018cf5
---- /dev/null
++++ b/public/assets/ov-mark.js
@@@ -0,0 +1,59 @@
++/**
++ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
++ * <span class="ov-mark"></span> → 32px animated mark
++ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
++ * The O is a ring with a comet arc running around it, the V is drawn inside with a light
++ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
++ */
++(function () {
++ 'use strict';
++ if (window.__ovMark) return; window.__ovMark = true;
++ let n = 0;
++ const CSS = `
++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}
++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}
++.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
++.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
++.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
++.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
++.ov-mark .d{fill:#fff;transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
++.ov-mark .o2{opacity:.5}
++.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
++.ov-mark:hover .c{animation-duration:.9s}
++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
++.ov-mark[data-static] .o{display:none}
++@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
++@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
++@keyframes ovmComet{to{transform:rotate(360deg)}}
++@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
++ function svg(id) {
++ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
++<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
++<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
++<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
++<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
++<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
++<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
++ }
++ function mount(root) {
++ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {
++ el.setAttribute('data-ov', '1');
++ const size = parseInt(el.getAttribute('data-size'), 10);
++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
++ el.innerHTML = svg('ovm' + (++n) + '_');
++ });
++ }
++ function init() {
++ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
++ mount();
++ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ }
++ window.ovMarkMount = mount;
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++})();
ddiff --git a/public/index.html b/public/index.html
iindex 3ed8319d..702c26a8 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -132,6 +132,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
}
</style>
<script src="/shared/theme-loader.js"></script>
++<script src="/assets/ov-mark.js"></script>
</head>
<body>
@@@ -143,7 +144,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- Hero -->
<section class="hero">
-- <div class="hero-badge"><i class="fa-solid fa-circle-nodes"></i> The OpenVibe Network — Community-Driven, No Profit</div>
++ <div class="hero-badge"><span class="ov-mark" data-size="20"></span> The OpenVibe Network — Community-Driven, No Profit</div>
<h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
<p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. No ads, no tracking, no nonsense.</p>
<div class="hero-ctas" id="hero-guest-ctas">
@@@ -197,7 +198,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- SSO strip -->
<section class="sso-strip">
<div class="sso-inner">
-- <i class="fa-solid fa-circle-nodes big"></i>
++ <span class="ov-mark" data-size="44" style="margin-bottom:12px"></span>
<h3>Your identity, everywhere</h3>
<p>openvibe.network is the identity service behind it all: single sign-on, profiles, themes that follow you across every app, notifications, and the network-wide OpenCoins wallet.</p>
</div>
@@@ -208,7 +209,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- Footer -->
<footer>
<div class="foot-inner">
-- <div class="foot-brand"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</div>
++ <div class="foot-brand"><span class="ov-mark" data-size="24"></span> OpenVibe</div>
<nav class="foot-links">
<a href="https://openvibe.live">Live</a>
<a href="https://openvibe.tools">Tools</a>
ddiff --git a/public/login.html b/public/login.html
iindex 279e2027..2366f686 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -81,7 +81,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
/* Logo */
.logo{text-align:center;margin-bottom:28px}
--.logo .flame{font-size:48px;display:block;margin-bottom:8px;animation:flame-pulse 2s ease-in-out infinite}
++.logo .flame{display:block;margin:0 auto 8px;width:72px;height:72px;color:var(--accent-light,var(--accent,#a78bfa))}
@keyframes flame-pulse{0%,100%{transform:scale(1);filter:brightness(1)}50%{transform:scale(1.08);filter:brightness(1.2)}}
.logo h1{font-size:22px;font-weight:800;letter-spacing:-.5px;margin-bottom:4px}
.logo p{font-size:12px;color:var(--text-muted);font-weight:500}
@@@ -215,7 +215,8 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@media(max-width:480px){
.login-card{padding:28px 24px;border-radius:16px}
-- .logo .flame{font-size:36px}
++ .logo .flame{width:56px;height:56px}
++ .logo .flame .ov-mark{width:56px!important;height:56px!important}
.logo h1{font-size:18px}
.login-container{padding:16px}
}
@@@ -242,7 +243,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<div class="login-container">
<div class="login-card">
<div class="logo">
-- <span class="flame"><i class="fa-solid fa-circle-nodes"></i></span>
++ <span class="flame"><span class="ov-mark" data-size="72"></span></span>
<h1>OpenVibe</h1>
<p>One account. All of OpenVibe.</p>
</div>
@@@ -411,7 +412,7 @@ function clearAuthCookie() {
function setAuthCookie(token) {
const secure = location.protocol === 'https:' ? ';Secure' : '';
-- document.cookie = `ov_token=${token};path=/;max-age=${60*60*24*30};SameSite=Lax${secure}`;
++ document.cookie = `ov_token=${token};path=/;max-age=${60*60*24*90};SameSite=Lax${secure}`;
}
function clearLocalAuthState() {
@@@ -478,6 +479,16 @@ function saveAnonAccount(user, token) {
return entry;
}
++function prefillAccount(account, msg) {
++ try {
++ if (typeof switchTab === 'function') { try { switchTab('login'); } catch { /* */ } }
++ const u = document.querySelector('form input[name="username"]');
++ const pw = document.querySelector('form input[name="password"]');
++ if (u) u.value = account.username || '';
++ if (pw) { pw.value = ''; pw.focus(); }
++ } catch { /* */ }
++ toast(msg || 'Enter your password to continue.', 'info');
++}
function removeAccount(id) {
const accounts = getSavedAccounts().filter(a => a.id !== id);
localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
@@@ -562,7 +573,7 @@ function renderChooser() {
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>'">`
-- : '<i class="fa-solid fa-circle-nodes"></i>';
++ : '<span class="ov-mark" data-size="28" data-static="1"></span>';
item.innerHTML = `
<div class="avatar">${avatarContent}</div>
<div class="info">
@@@ -605,9 +616,10 @@ async function selectAccount(account) {
account.token = validToken;
saveAccount(refreshData.user || account, validToken);
} else {
-- // Refresh also failed — session is truly expired
-- toast('Session expired for ' + (account.display_name || account.username) + '. Please sign in again.', 'error');
-- removeAccount(account.id);
++ // Refresh also failed — this account needs its password again. Keep it in the
++ // list and prefill it instead of deleting it (deleting it is what made people
++ // re-add their account over and over).
++ prefillAccount(account, 'Welcome back, ' + (account.display_name || account.username) + ' — enter your password to continue.');
return;
}
}
@@@ -638,8 +650,7 @@ async function selectAccount(account) {
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
-- toast('Session expired for ' + (account.display_name || account.username) + '. Please sign in again.', 'error');
-- removeAccount(account.id);
++ prefillAccount(account, 'Welcome back, ' + (account.display_name || account.username) + ' — enter your password to continue.');
return;
}
if (res.status === 403) {
@@@ -996,6 +1007,7 @@ async function continueAnonymous() {
}
})();
</script>
++ <script src="/assets/ov-mark.js"></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</html>
ddiff --git a/public/my.html b/public/my.html
iindex 5584e4ee..9c981f97 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -549,6 +549,8 @@ async function apiFetch(path, opts = {}) {
const authToken = getAuthToken();
if (authToken) headers.Authorization = 'Bearer ' + authToken;
const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ // Sliding sessions: the server hands back a renewed token when the old one is due.
++ try { const fresh = res.headers.get('X-OV-Token'); if (fresh) { localStorage.setItem('ov_token', fresh); document.cookie = `ov_token=${fresh};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; } } catch { /* */ }
let data = {};
try { data = await res.json(); } catch { /* non-JSON (e.g. proxy error page) */ }
if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
@@@ -888,6 +890,15 @@ async function loadUser() {
// A genuine auth failure → send them to log in. Everything else (offline, weak signal,
// timeout, server hiccup) → show a clear reason + retry instead of a stuck "Loading…".
if (err.status === 401 || /401|Authentication|not found|revoked|Unauthorized/i.test(err.message || '')) {
++ // One refresh attempt before giving up on the session (a stale localStorage token
++ // with a still-good cookie used to bounce people to the login page for nothing).
++ if (!window._ovRefreshTried) {
++ window._ovRefreshTried = true;
++ try {
++ const r = await fetchWithTimeout(API + '/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { Authorization: 'Bearer ' + getAuthToken() } : {}) }, credentials: 'include' });
++ if (r.ok) { const d = await r.json(); if (d.token) { localStorage.setItem('ov_token', d.token); document.cookie = `ov_token=${d.token};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; location.reload(); return; } }
++ } catch { /* fall through */ }
++ }
window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
return;
}
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 32f6cc0c..019b826c 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -38,7 +38,27 @@ router.get('/authorize', (req, res) => {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
-- // Always redirect to login/chooser — let the user pick which account to use
++ // prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
++ // live/renewable openvibe.network session, continue as that account with no chooser. With no
++ // session, bounce straight back with error=login_required so the app can stay quiet.
++ 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) });
++ 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 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 (?, ?, ?, ?, ?, ?)`)
++ .run(code, client_id, out.user.id, redirect_uri, scope || 'profile theme', expiresAt);
++ return res.redirect(`${redirect_uri}${sep}code=${code}&state=${state || ''}`);
++ }
++ } catch { /* fall through */ }
++ return res.redirect(`${redirect_uri}${sep}error=login_required&state=${state || ''}`);
++ }
++ // Otherwise the account chooser — the user picks which account to continue with
const loginParams = new URLSearchParams({
client_id,
client_name: client.name || client_id,
@@@ -98,7 +118,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: 30 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' });
++ res.cookie('ov_token', token, { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' });
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 2e42297f..3618ca10 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -80,33 +80,9 @@ function sanitizeUser(user) {
return safe;
}
--function requireAuth(req, res, next) {
-- const authHeader = req.headers.authorization;
-- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
-- if (!token) return res.status(401).json({ error: 'Authentication required' });
--
-- const config = getConfig(req);
-- const publicKey = req.app.locals.publicKey;
-- const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
--
-- try {
-- const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
-- const db = getDb(req);
-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
-- if (!user) return res.status(401).json({ error: 'User not found' });
-- if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
-- if (user.token_valid_after) {
-- const tokenIat = decoded.iat * 1000;
-- const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
-- if (tokenIat < validAfter) return res.status(401).json({ error: 'Token revoked' });
-- }
-- req.user = user;
-- req.token = token;
-- next();
-- } catch {
-- return res.status(401).json({ error: 'Invalid or expired token' });
-- }
--}
++const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');
++// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).
++const requireAuth = makeRequireAuth((req) => ({ db: getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);
// ── Register ─────────────────────────────────────────────────
router.post('/register', (req, res) => {
@@@ -343,7 +319,7 @@ router.post('/refresh', (req, res) => {
ignoreExpiration: true,
});
const expiredAt = decoded.exp * 1000;
-- const gracePeriod = 7 * 24 * 60 * 60 * 1000; // 7 days
++ const gracePeriod = SESSION_GRACE_MS; // same 60-day window the sliding guard uses
if (Date.now() - expiredAt > gracePeriod) {
return res.status(401).json({ error: 'Token expired beyond grace period' });
}
@@@ -374,7 +350,7 @@ router.post('/refresh', (req, res) => {
// Update session cookie — host-only on openvibe.network (NO Domain attribute)
res.cookie('ov_token', newToken, {
httpOnly: false,
-- maxAge: 30 * 24 * 60 * 60 * 1000,
++ maxAge: 90 * 24 * 60 * 60 * 1000,
sameSite: 'Lax',
secure: true,
path: '/',
ddiff --git a/server/auth/session.js b/server/auth/session.js
nnew file mode 100644
iindex 00000000..f3943f68
---- /dev/null
++++ b/server/auth/session.js
@@@ -0,0 +1,68 @@
++/**
++ * session.js — one place that decides "is this token a live session?", with SLIDING renewal.
++ *
++ * Access tokens are short-ish (config.jwt.accessTokenExpiry) but a session is not over when the
++ * token's exp passes: any token that expired within GRACE_MS, for a user that still exists, is
++ * not banned and has not revoked tokens since (token_valid_after), is accepted AND renewed —
++ * the caller gets a fresh token to set as the cookie / hand back in the X-OV-Token header.
++ * Sessions therefore only end after GRACE_MS of not showing up at all, on logout, on password
++ * change, or on ban. This is what stops "session expired, sign in again" every day.
++ */
++'use strict';
++const jwt = require('jsonwebtoken');
++
++const GRACE_MS = 60 * 24 * 60 * 60 * 1000; // renewable for 60 days after the token's exp
++
++function verifySession(token, { db, publicKey, config }) {
++ if (!token) return { error: 'Authentication required', status: 401 };
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ let decoded, expired = false;
++ try {
++ decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ } catch (err) {
++ if (err && err.name === 'TokenExpiredError') {
++ try { decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer, ignoreExpiration: true }); expired = true; }
++ catch { return { error: 'Invalid token', status: 401 }; }
++ if (Date.now() - decoded.exp * 1000 > GRACE_MS) return { error: 'Session expired', status: 401, beyondGrace: true };
++ } else {
++ return { error: 'Invalid or expired token', status: 401 };
++ }
++ }
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ if (!user) return { error: 'User not found', status: 401 };
++ if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
++ if (user.token_valid_after) {
++ const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
++ if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };
++ }
++ // Renew when expired, or when more than half of its lifetime is gone (keeps busy users' cookies fresh).
++ const life = (decoded.exp - decoded.iat) * 1000;
++ const renew = expired || (Number.isFinite(life) && life > 0 && Date.now() > decoded.iat * 1000 + life / 2);
++ return { user, decoded, renew };
++}
++
++const COOKIE = { httpOnly: false, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' };
++/** 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 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 });
++ req.user = out.user;
++ req.token = token;
++ if (out.renew && typeof signToken === 'function') {
++ try {
++ const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config);
++ req.token = fresh;
++ res.cookie('ov_token', fresh, COOKIE);
++ 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 */ }
++ }
++ next();
++ };
++}
++
++module.exports = { verifySession, makeRequireAuth, GRACE_MS, COOKIE };
ddiff --git a/server/config.js b/server/config.js
iindex f18fe229..9bc014aa 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -19,7 +19,7 @@ module.exports = {
// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
-- accessTokenExpiry: '24h',
++ accessTokenExpiry: '7d',
refreshTokenExpiry: '30d',
// issuer is the canonical public network URL — updated at runtime from registry
issuer: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
ddiff --git a/server/index.js b/server/index.js
iindex 4879055a..2b46be26 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -345,28 +345,8 @@ discordService.init().catch(err => console.error('[Discord] Init error:', err.me
// requireAuth helper (needed by route factories)
const authRoutes = require('./auth/routes');
const jwt = require('jsonwebtoken');
--function requireAuth(req, res, next) {
-- const authHeader = req.headers.authorization;
-- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
-- if (!token) return res.status(401).json({ error: 'Authentication required' });
-- const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
-- try {
-- const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
-- if (!user) return res.status(401).json({ error: 'User not found' });
-- if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
-- if (user.token_valid_after) {
-- const tokenIat = decoded.iat * 1000;
-- const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
-- if (tokenIat < validAfter) return res.status(401).json({ error: 'Token revoked' });
-- }
-- req.user = user;
-- req.token = token;
-- next();
-- } catch {
-- return res.status(401).json({ error: 'Invalid or expired token' });
-- }
--}
++// Sliding session guard (server/auth/session.js): renewable tokens are accepted and renewed.
++const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex d4496050..762d8a94 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -12,19 +12,21 @@ const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
--function optionalAuth(req, _res, next) {
-- const token = req.headers.authorization?.startsWith('Bearer ')
-- ? req.headers.authorization.slice(7)
-- : req.cookies?.ov_token;
-- if (token) {
-- const config = getConfig(req);
-- const publicKey = req.app.locals.publicKey;
-- const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
-- try {
-- const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
-- req.user = decoded;
-- } catch { /* not authenticated, continue */ }
-- }
++function optionalAuth(req, res, next) {
++ // Sliding sessions (server/auth/session.js): renewable tokens count, and get renewed.
++ const authHeader = req.headers.authorization;
++ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
++ if (!token) return next();
++ try {
++ const { verifySession, COOKIE } = require('../auth/session');
++ const out = verifySession(token, { db: getDb(req), publicKey: req.app.locals.publicKey, config: req.app.locals.config });
++ if (out.error) return next();
++ // Routes here read both req.user.sub (JWT shape) and req.user.id (row shape) — give them both.
++ req.user = { ...out.decoded, ...out.user, sub: out.user.id, id: out.user.id }; req.token = token;
++ if (out.renew) {
++ try { const fresh = require('../auth/routes').signToken(out.user, req.app.locals.privateKey, req.app.locals.config); res.cookie('ov_token', fresh, COOKIE); res.set('X-OV-Token', fresh); res.set('Access-Control-Expose-Headers', 'X-OV-Token'); } catch { /* */ }
++ }
++ } catch { /* treat as anonymous */ }
next();
}