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
Changes
nnew file mode 100644@@@ -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();++})();@@@ -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>@@@ -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>@@@ -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;}@@@ -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 withconst 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 || ''}` });@@@ -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 usesif (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: '/',nnew file mode 100644@@@ -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 };@@@ -19,7 +19,7 @@ module.exports = {// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pemprivateKeyPath: 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 registryissuer: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',@@@ -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).@@@ -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();}
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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();
+})();
@@ -132,6 +132,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -132,6 +132,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
}
}
</style>
</style>
<script src="/shared/theme-loader.js"></script>
<script src="/shared/theme-loader.js"></script>
+<script src="/assets/ov-mark.js"></script>
</head>
</head>
<body>
<body>
@@ -143,7 +144,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -143,7 +144,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- Hero -->
<!-- Hero -->
<section class="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>
<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>
<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">
<div class="hero-ctas" id="hero-guest-ctas">
@@ -197,7 +198,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -197,7 +198,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- SSO strip -->
<!-- SSO strip -->
<section class="sso-strip">
<section class="sso-strip">
<div class="sso-inner">
<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>
<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>
<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>
</div>
@@ -208,7 +209,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -208,7 +209,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<!-- Footer -->
<!-- Footer -->
<footer>
<footer>
<div class="foot-inner">
<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">
<nav class="foot-links">
<a href="https://openvibe.live">Live</a>
<a href="https://openvibe.live">Live</a>
<a href="https://openvibe.tools">Tools</a>
<a href="https://openvibe.tools">Tools</a>
@@ -81,7 +81,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -81,7 +81,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
/* Logo */
/* Logo */
.logo{text-align:center;margin-bottom:28px}
.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)}}
@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 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}
.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
@@ -215,7 +215,8 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@media(max-width:480px){
@media(max-width:480px){
.login-card{padding:28px 24px;border-radius:16px}
.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}
.logo h1{font-size:18px}
.login-container{padding:16px}
.login-container{padding:16px}
}
}
@@ -242,7 +243,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -242,7 +243,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<div class="login-container">
<div class="login-container">
<div class="login-card">
<div class="login-card">
<div class="logo">
<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>
<h1>OpenVibe</h1>
<p>One account. All of OpenVibe.</p>
<p>One account. All of OpenVibe.</p>
</div>
</div>
@@ -411,7 +412,7 @@ function clearAuthCookie() {
@@ -411,7 +412,7 @@ function clearAuthCookie() {
function setAuthCookie(token) {
function setAuthCookie(token) {
const secure = location.protocol === 'https:' ? ';Secure' : '';
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() {
function clearLocalAuthState() {
@@ -478,6 +479,16 @@ function saveAnonAccount(user, token) {
@@ -478,6 +479,16 @@ function saveAnonAccount(user, token) {
return entry;
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) {
function removeAccount(id) {
const accounts = getSavedAccounts().filter(a => a.id !== id);
const accounts = getSavedAccounts().filter(a => a.id !== id);
localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
@@ -562,7 +573,7 @@ function renderChooser() {
@@ -562,7 +573,7 @@ function renderChooser() {
item.className = 'account-item' + (String(account.id) === String(activeId) ? ' active-account' : '');
item.className = 'account-item' + (String(account.id) === String(activeId) ? ' active-account' : '');
const avatarContent = account.avatar_url
const avatarContent = account.avatar_url
? `<img src="${escapeHtml(account.avatar_url)}" alt="" onerror="this.parentElement.innerHTML='<i class=\'fa-solid fa-circle-nodes\'></i>'">`
? `<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 = `
item.innerHTML = `
<div class="avatar">${avatarContent}</div>
<div class="avatar">${avatarContent}</div>
<div class="info">
<div class="info">
@@ -605,9 +616,10 @@ async function selectAccount(account) {
@@ -605,9 +616,10 @@ async function selectAccount(account) {
account.token = validToken;
account.token = validToken;
saveAccount(refreshData.user || account, validToken);
saveAccount(refreshData.user || account, validToken);
} else {
} 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;
return;
}
}
}
}
@@ -638,8 +650,7 @@ async function selectAccount(account) {
@@ -638,8 +650,7 @@ async function selectAccount(account) {
const data = await res.json();
const data = await res.json();
if (!res.ok) {
if (!res.ok) {
if (res.status === 401) {
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;
return;
}
}
if (res.status === 403) {
if (res.status === 403) {
@@ -996,6 +1007,7 @@ async function continueAnonymous() {
@@ -996,6 +1007,7 @@ async function continueAnonymous() {
}
}
})();
})();
</script>
</script>
+ <script src="/assets/ov-mark.js"></script>
<script src="https://openvibe.live/live-notify.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</body>
</html>
</html>
@@ -549,6 +549,8 @@ async function apiFetch(path, opts = {}) {
@@ -549,6 +549,8 @@ async function apiFetch(path, opts = {}) {
const authToken = getAuthToken();
const authToken = getAuthToken();
if (authToken) headers.Authorization = 'Bearer ' + authToken;
if (authToken) headers.Authorization = 'Bearer ' + authToken;
const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
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 = {};
let data = {};
try { data = await res.json(); } catch { /* non-JSON (e.g. proxy error page) */ }
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; }
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() {
@@ -888,6 +890,15 @@ async function loadUser() {
// A genuine auth failure → send them to log in. Everything else (offline, weak signal,
// 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…".
// 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 || '')) {
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);
window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
return;
return;
}
}
@@ -38,7 +38,27 @@ router.get('/authorize', (req, res) => {
@@ -38,7 +38,27 @@ router.get('/authorize', (req, res) => {
return res.status(400).json({ error: 'Invalid redirect_uri' });
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({
const loginParams = new URLSearchParams({
client_id,
client_id,
client_name: client.name || client_id,
client_name: client.name || client_id,
@@ -98,7 +118,7 @@ router.post('/confirm', (req, res) => {
@@ -98,7 +118,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: 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('?') ? '&' : '?';
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 || ''}` });
@@ -80,33 +80,9 @@ function sanitizeUser(user) {
@@ -80,33 +80,9 @@ function sanitizeUser(user) {
return safe;
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 ─────────────────────────────────────────────────
// ── Register ─────────────────────────────────────────────────
router.post('/register', (req, res) => {
router.post('/register', (req, res) => {
@@ -343,7 +319,7 @@ router.post('/refresh', (req, res) => {
@@ -343,7 +319,7 @@ router.post('/refresh', (req, res) => {
ignoreExpiration: true,
ignoreExpiration: true,
});
});
const expiredAt = decoded.exp * 1000;
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) {
if (Date.now() - expiredAt > gracePeriod) {
return res.status(401).json({ error: 'Token expired beyond grace period' });
return res.status(401).json({ error: 'Token expired beyond grace period' });
}
}
@@ -374,7 +350,7 @@ router.post('/refresh', (req, res) => {
@@ -374,7 +350,7 @@ router.post('/refresh', (req, res) => {
// Update session cookie — host-only on openvibe.network (NO Domain attribute)
// Update session cookie — host-only on openvibe.network (NO Domain attribute)
res.cookie('ov_token', newToken, {
res.cookie('ov_token', newToken, {
httpOnly: false,
httpOnly: false,
- maxAge: 30 * 24 * 60 * 60 * 1000,
+ maxAge: 90 * 24 * 60 * 60 * 1000,
sameSite: 'Lax',
sameSite: 'Lax',
secure: true,
secure: true,
path: '/',
path: '/',
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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 };
@@ -19,7 +19,7 @@ module.exports = {
@@ -19,7 +19,7 @@ module.exports = {
// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
- accessTokenExpiry: '24h',
+ accessTokenExpiry: '7d',
refreshTokenExpiry: '30d',
refreshTokenExpiry: '30d',
// issuer is the canonical public network URL — updated at runtime from registry
// 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',
issuer: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
@@ -345,28 +345,8 @@ discordService.init().catch(err => console.error('[Discord] Init error:', err.me
@@ -345,28 +345,8 @@ discordService.init().catch(err => console.error('[Discord] Init error:', err.me
// requireAuth helper (needed by route factories)
// requireAuth helper (needed by route factories)
const authRoutes = require('./auth/routes');
const authRoutes = require('./auth/routes');
const jwt = require('jsonwebtoken');
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 ───────────────────────────────────────────────────
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
// Public key endpoint (services fetch this to verify JWTs).
@@ -12,19 +12,21 @@ const router = express.Router();
@@ -12,19 +12,21 @@ const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
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();
next();
}
}