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)
Changes
nnew file mode 100644@@@ -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";@@@ -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 };@@@ -17,8 +17,8 @@ const hash = (token) => crypto.createHash('sha256').update(String(token)).digestfunction 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,};}@@@ -52,6 +52,7 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', cconst 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;@@@ -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='));
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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";
@@ -26,10 +26,27 @@ const FLOW_COOKIE = 'watch_oauth';
@@ -26,10 +26,27 @@ const FLOW_COOKIE = 'watch_oauth';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
const FLOW_TTL_MS = 10 * 60 * 1000;
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.
// 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' && /[\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 '/';
if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
return next;
return next;
}
}
@@ -100,7 +117,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -100,7 +117,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });
const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });
r.get('/login', async (req, res) => {
r.get('/login', async (req, res) => {
- const next = sanitizeNext(req.query.next);
+ const next = sanitizeNext(req.query.next, config);
const started = await sdk.startAuthorization({
const started = await sdk.startAuthorization({
network: config.networkUrl,
network: config.networkUrl,
clientId: config.oauth.clientId,
clientId: config.oauth.clientId,
@@ -118,7 +135,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -118,7 +135,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
let flow = null;
let flow = null;
try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
- const next = flow ? sanitizeNext(flow.next) : '/';
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
const query = new URLSearchParams();
const query = new URLSearchParams();
for (const [k, v] of Object.entries(req.query)) query.set(k, String(v));
for (const [k, v] of Object.entries(req.query)) query.set(k, String(v));
let code;
let code;
@@ -154,8 +171,11 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -154,8 +171,11 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
username: typeof claims.username === 'string' ? claims.username : null,
username: typeof claims.username === 'string' ? claims.username : null,
displayName: typeof claims.display_name === 'string' ? claims.display_name : null,
displayName: typeof claims.display_name === 'string' ? claims.display_name : null,
role: typeof claims.role === 'string' ? claims.role : null,
role: typeof claims.role === 'string' ? claims.role : null,
+ avatarUrl: claims.avatar_url, profileColor: claims.profile_color,
});
});
setSessionCookie(res, session);
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);
return res.redirect(next);
} catch (err) {
} catch (err) {
log.warn(`[sso] sign-in refused: ${(err && err.code) || (err && err.message) || 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 =
@@ -176,7 +196,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
r.get('/logout', (req, res, next) => {
r.get('/logout', (req, res, next) => {
if (sameOriginRequest(req)) return next();
if (sameOriginRequest(req)) return next();
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
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">`
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">`
+ `<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>`);
+ `<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 =
@@ -186,7 +206,8 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
const token = req.cookies && req.cookies[SESSION_COOKIE];
const token = req.cookies && req.cookies[SESSION_COOKIE];
if (token) { try { await sessions.revoke(token); } catch { /* the cookie is cleared anyway */ } }
if (token) { try { await sessions.revoke(token); } catch { /* the cookie is cleared anyway */ } }
clearSessionCookie(res);
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) => {
r.get('/me', (req, res) => {
@@ -196,7 +217,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -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 });
if (!c[SESSION_COOKIE] && !req.get('authorization')) return res.json({ user: null });
const v = req.viewer;
const v = req.viewer;
if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
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;
return r;
}
}
@@ -204,4 +225,4 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -204,4 +225,4 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
return { middleware, routes, verifySessionToken, SESSION_COOKIE, FLOW_COOKIE };
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 };
@@ -17,8 +17,8 @@ const hash = (token) => crypto.createHash('sha256').update(String(token)).digest
@@ -17,8 +17,8 @@ const hash = (token) => crypto.createHash('sha256').update(String(token)).digest
function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS }) {
function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS }) {
const st = {
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 = ?'),
get: db.prepare('SELECT * FROM web_sessions WHERE id = ?'),
touch: db.prepare('UPDATE web_sessions SET last_seen_at = ? 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 = ?'),
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 })
@@ -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).
* 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.
* 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');
if (typeof subject !== 'string' || !subject) throw new Error('a session needs a subject');
const token = newToken();
const token = newToken();
const t = now();
const t = now();
@@ -41,6 +41,9 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
@@ -41,6 +41,9 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
username: username == null ? null : String(username).slice(0, 64),
username: username == null ? null : String(username).slice(0, 64),
display_name: displayName == null ? null : String(displayName).slice(0, 80),
display_name: displayName == null ? null : String(displayName).slice(0, 80),
role: role == null ? null : String(role).slice(0, 32),
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,
created_at: t, last_seen_at: t, expires_at: t + ttlMs,
});
});
return token;
return token;
@@ -85,6 +88,8 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
@@ -85,6 +88,8 @@ function createSessions({ db, now = () => Date.now(), ttlMs = DEFAULT_TTL_MS })
username: row.username || null,
username: row.username || null,
displayName: row.display_name || row.username || 'you',
displayName: row.display_name || row.username || 'you',
role: row.role || 'user',
role: row.role || 'user',
+ avatarUrl: row.avatar_url || null,
+ profileColor: row.profile_color || null,
};
};
}
}
@@ -52,6 +52,7 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
@@ -52,6 +52,7 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
const token = serviceAuth.signServiceToken({
const token = serviceAuth.signServiceToken({
iss: issuer, sub: String(u.id), subject_id: u.subject, username: u.username, display_name: u.display_name,
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')}`,
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);
}, privateKey);
st.tokens.push(token);
st.tokens.push(token);
return token;
return token;
@@ -62,6 +62,8 @@ t('the signed-out pages say what Watch is and ask for a sign-in', async () => {
@@ -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 () => {
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
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);
const out = await svc.login(who);
assert.strictEqual(out.status, 302);
assert.strictEqual(out.status, 302);
assert.strictEqual(out.location, '/watches');
assert.strictEqual(out.location, '/watches');
@@ -72,6 +74,8 @@ t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () =
@@ -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.status, 200);
assert.strictEqual(meRes.json().user.subject_id, who.subject);
assert.strictEqual(meRes.json().user.subject_id, who.subject);
assert.strictEqual(meRes.json().user.username, 'ada');
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.
// A garbage session cookie is a guest, not an error.
const guest = await svc.get('/auth/me', { cookie: 'watch_session=nonsense' });
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 () =
@@ -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.
// 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: {} });
const bye = await svc.get('/auth/logout', { cookie: out.cookie, method: 'POST', form: {} });
assert.strictEqual(bye.status, 303);
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 });
const after = await svc.get('/watches', { cookie: out.cookie });
assert.match(after.text, /Sign in with OpenVibe/);
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.
// 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 started = await svc.get('/auth/login?next=%2Fwatches');
const flow = started.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('watch_oauth='));
const flow = started.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('watch_oauth='));