openvibers/openvibe.watch Commits

A quiet sign-in returns to the page it started on (an absolute next on our own origin is its path), the navbar shows the person's avatar, and sign-in/out clear the browser's copy of the site (#84)

OP OpenVibers committed 22 days ago · b21ee721e2 · 5 files, +56 −11

Changes

ddiff --git a/migrations/0012_session_avatar.sql b/migrations/0012_session_avatar.sql
nnew file mode 100644
iindex 0000000..0ef081c
---- /dev/null
++++ b/migrations/0012_session_avatar.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- The shared navbar shows the signed-in person's avatar from /auth/me (server/auth/sso.js): the session keeps the
++-- avatar and colour Network's token carried at sign-in, as the other sites' sessions do (2026-10-22: every scaffolded
++-- site answered /auth/me without them, so the navbar drew an initial). Nullable and additive: an older release never
++-- reads them.
++ALTER TABLE web_sessions ADD COLUMN IF NOT EXISTS avatar_url text COLLATE "C";
++ALTER TABLE web_sessions ADD COLUMN IF NOT EXISTS profile_color text COLLATE "C";
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex c69c3bd..a68e164 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -26,10 +26,27 @@ const FLOW_COOKIE = 'watch_oauth';
const HINT_COOKIE = 'ov_sso_hint';
const FLOW_TTL_MS = 10 * 60 * 1000;
--/** Same-site relative paths only (never protocol-relative, never another origin). */
--function sanitizeNext(next) {
++/** This site's own origin (BASE_URL). */
++function ownOrigins(config) {
++ const out = new Set();
++ try { if (config && config.baseUrl) out.add(new URL(config.baseUrl).origin); } catch { /* not a URL */ }
++ return out;
++}
++
++/**
++ * Same-site relative paths only (never protocol-relative, never another origin). An absolute URL on this site's own
++ * origin counts as its path: the shared navbar's silent sign-in sends the whole page address, and until 2026-10-22
++ * that landed on the home page.
++ */
++function sanitizeNext(next, config) {
// Browsers drop tab/newline and read a backslash as "/": "/<TAB>/evil.com" would leave the site.
if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
++ let u = null;
++ try { u = new URL(next); } catch { return '/'; }
++ if (!ownOrigins(config).has(u.origin)) return '/';
++ next = `${u.pathname}${u.search}${u.hash}`;
++ }
if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
return next;
}
@@@ -100,7 +117,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });
r.get('/login', async (req, res) => {
-- const next = sanitizeNext(req.query.next);
++ const next = sanitizeNext(req.query.next, config);
const started = await sdk.startAuthorization({
network: config.networkUrl,
clientId: config.oauth.clientId,
@@@ -118,7 +135,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
let flow = null;
try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
-- const next = flow ? sanitizeNext(flow.next) : '/';
++ const next = flow ? sanitizeNext(flow.next, config) : '/';
const query = new URLSearchParams();
for (const [k, v] of Object.entries(req.query)) query.set(k, String(v));
let code;
@@@ -154,8 +171,11 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
username: typeof claims.username === 'string' ? claims.username : null,
displayName: typeof claims.display_name === 'string' ? claims.display_name : null,
role: typeof claims.role === 'string' ? claims.role : null,
++ avatarUrl: claims.avatar_url, profileColor: claims.profile_color,
});
setSessionCookie(res, session);
++ // Whatever this browser kept of the site was drawn for a guest: drop it.
++ res.set('Clear-Site-Data', '"cache"');
return res.redirect(next);
} catch (err) {
log.warn(`[sso] sign-in refused: ${(err && err.code) || (err && err.message) || err}`);
@@@ -176,7 +196,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
r.get('/logout', (req, res, next) => {
if (sameOriginRequest(req)) return next();
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
return res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ `<p>Sign out of OpenVibe.Watch?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
@@@ -186,7 +206,8 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
const token = req.cookies && req.cookies[SESSION_COOKIE];
if (token) { try { await sessions.revoke(token); } catch { /* the cookie is cleared anyway */ } }
clearSessionCookie(res);
-- return res.redirect(303, sanitizeNext(req.query.next));
++ res.set('Clear-Site-Data', '"cache"');
++ return res.redirect(303, sanitizeNext(req.query.next, config));
});
r.get('/me', (req, res) => {
@@@ -196,7 +217,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
if (!c[SESSION_COOKIE] && !req.get('authorization')) return res.json({ user: null });
const v = req.viewer;
if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
-- return res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
++ return res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
});
return r;
}
@@@ -204,4 +225,4 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
return { middleware, routes, verifySessionToken, SESSION_COOKIE, FLOW_COOKIE };
}
--module.exports = { createSso, sanitizeNext, parseCookies, SESSION_COOKIE, FLOW_COOKIE, HINT_COOKIE };
++module.exports = { createSso, sanitizeNext, ownOrigins, parseCookies, SESSION_COOKIE, FLOW_COOKIE, HINT_COOKIE };
ddiff --git a/server/sessions.js b/server/sessions.js
iindex 3d911b4..f2206fc 100644
---- a/server/sessions.js
++++ b/server/sessions.js
@@@ -17,8 +17,8 @@ const hash = (token) => crypto.createHash('sha256').update(String(token)).digest
function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS }) {
const st = {
-- insert: db.prepare(`INSERT INTO web_sessions (id, subject, username, display_name, role, created_at, last_seen_at, expires_at, revoked_at)
-- VALUES (@id, @subject, @username, @display_name, @role, @created_at, @last_seen_at, @expires_at, NULL)`),
++ insert: db.prepare(`INSERT INTO web_sessions (id, subject, username, display_name, role, avatar_url, profile_color, created_at, last_seen_at, expires_at, revoked_at)
++ VALUES (@id, @subject, @username, @display_name, @role, @avatar_url, @profile_color, @created_at, @last_seen_at, @expires_at, NULL)`),
get: db.prepare('SELECT * FROM web_sessions WHERE id = ?'),
touch: db.prepare('UPDATE web_sessions SET last_seen_at = ? WHERE id = ?'),
revoke: db.prepare('UPDATE web_sessions SET revoked_at = ? WHERE id = ?'),
@@@ -32,7 +32,7 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
* Create a session for a verified person. Returns the cookie token (shown once, never stored).
* The subject is required: a session with nobody's subject owns nothing and is refused.
*/
-- async function create({ subject, username = null, displayName = null, role = null }) {
++ async function create({ subject, username = null, displayName = null, role = null, avatarUrl = null, profileColor = null }) {
if (typeof subject !== 'string' || !subject) throw new Error('a session needs a subject');
const token = newToken();
const t = now();
@@@ -41,6 +41,9 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
username: username == null ? null : String(username).slice(0, 64),
display_name: displayName == null ? null : String(displayName).slice(0, 80),
role: role == null ? null : String(role).slice(0, 32),
++ // The navbar's avatar (an https image) and colour (hex), from Network's token at sign-in.
++ avatar_url: typeof avatarUrl === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(avatarUrl) ? avatarUrl : null,
++ profile_color: typeof profileColor === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(profileColor) ? profileColor : null,
created_at: t, last_seen_at: t, expires_at: t + ttlMs,
});
return token;
@@@ -85,6 +88,8 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
username: row.username || null,
displayName: row.display_name || row.username || 'you',
role: row.role || 'user',
++ avatarUrl: row.avatar_url || null,
++ profileColor: row.profile_color || null,
};
}
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex 9583871..85b44bd 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -52,6 +52,7 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
const token = serviceAuth.signServiceToken({
iss: issuer, sub: String(u.id), subject_id: u.subject, username: u.username, display_name: u.display_name,
role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ ...(u.avatar_url ? { avatar_url: u.avatar_url } : {}), ...(u.profile_color ? { profile_color: u.profile_color } : {}),
}, privateKey);
st.tokens.push(token);
return token;
ddiff --git a/test/site.test.js b/test/site.test.js
iindex b8deb86..6d32045 100644
---- a/test/site.test.js
++++ b/test/site.test.js
@@@ -62,6 +62,8 @@ t('the signed-out pages say what Watch is and ask for a sign-in', async () => {
t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () => {
const who = svc.network.addUser('ada'); // same username, distinct subject for this flow test
++ who.avatar_url = 'https://openvibe.media/o/med_01JABCDEFGHJKMNPQRSTVWXYZ0';
++ who.profile_color = '#0af';
const out = await svc.login(who);
assert.strictEqual(out.status, 302);
assert.strictEqual(out.location, '/watches');
@@@ -72,6 +74,8 @@ t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () =
assert.strictEqual(meRes.status, 200);
assert.strictEqual(meRes.json().user.subject_id, who.subject);
assert.strictEqual(meRes.json().user.username, 'ada');
++ // The navbar's avatar and colour come along, from Network's token at sign-in (it drew an initial before 2026-10-22).
++ assert.deepStrictEqual([meRes.json().user.avatar_url, meRes.json().user.profile_color], [who.avatar_url, '#0af']);
// A garbage session cookie is a guest, not an error.
const guest = await svc.get('/auth/me', { cookie: 'watch_session=nonsense' });
@@@ -83,9 +87,16 @@ t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () =
// Sign-out revokes the session: the same cookie no longer reads a person.
const bye = await svc.get('/auth/logout', { cookie: out.cookie, method: 'POST', form: {} });
assert.strictEqual(bye.status, 303);
++ assert.strictEqual(bye.headers.get('clear-site-data'), '"cache"', 'what the browser kept of the site is dropped');
const after = await svc.get('/watches', { cookie: out.cookie });
assert.match(after.text, /Sign in with OpenVibe/);
++ // The navbar's quiet sign-in sends the whole page address: it comes back to that page, not the home page.
++ const { sanitizeNext } = require('../server/auth/sso');
++ const cfg = { baseUrl: 'https://openvibe.watch' };
++ assert.strictEqual(sanitizeNext('https://openvibe.watch/watches/w1?tab=runs', cfg), '/watches/w1?tab=runs');
++ for (const v of ['https://evil.com/x', 'https://openvibe.watch.evil.com/x', 'https://openvibe.watch//evil.com', 'http://openvibe.watch/x']) assert.strictEqual(sanitizeNext(v, cfg), '/', v);
++
// The state must match the browser that started: a callback with the wrong state is refused.
const started = await svc.get('/auth/login?next=%2Fwatches');
const flow = started.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('watch_oauth='));