openvibers/openvibe.host Commits

Trust only Host's own Host- session cookie on the dashboard (#16)

OP OpenVibers committed 3 Oct 2026 · 25c3ab4fdf · 8 files, +247 −16

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Host/pull/16

Changes

ddiff --git a/README.md b/README.md
iindex 2954313..1d65f35 100644
---- a/README.md
++++ b/README.md
@@@ -284,7 +284,7 @@ Requests resolve their site once from the `Host` header, read the site's active
**Why a separate registrable domain.** Tenant pages are arbitrary HTML and JavaScript. They live under `openvibe.host`, never under `openvibe.network` or `openvibe.live`, so no OpenVibe session cookie can ever reach them (`ovhost nginx tenants` refuses a sites domain under a first-party domain). The dashboard shares `openvibe.host` with the tenants, which makes tenant pages *same-site* with it. So:
--- the dashboard trusts only `__Host-` prefixed cookies (a subdomain cannot set them); the plain `ov_token` cookie the shared navbar reads is display-only and is never trusted;
++- the dashboard session is only Host's own `__Host-ov_host_session` cookie (HttpOnly, Secure, `Path=/`, no `Domain`; a subdomain cannot set it), written and cleared alongside the SDK's `ov_token` at sign-in, refresh and sign-out (`server/auth/sso.js`, read at `server/auth/viewer.js:99-103`). The plain `ov_token` cookie the shared navbar reads is display-only and is never trusted, a sign-in callback must match Host's own `__Host-ov_host_flow` state, and a refresh never changes the signed-in account. With `COOKIE_SECURE=false` (plain-http development) the names drop the `__Host-` prefix and are not Secure;
- every dashboard form needs the dashboard's own `Origin` **and** a form token (an HMAC of the signed-in subject); `SameSite` alone would not stop a tenant page;
- `/api/v1` ignores cookies entirely: `Authorization: Bearer` only;
- nginx strips `Cookie` and `Authorization` from tenant requests, hides `Set-Cookie` from tenant responses and allows only `GET`/`HEAD` with a 1 KB body limit.
ddiff --git a/docs/threat-review.md b/docs/threat-review.md
iindex b45efa9..43598b4 100644
---- a/docs/threat-review.md
++++ b/docs/threat-review.md
@@@ -20,7 +20,7 @@ Status column says which risks are **fixed**, **mitigated**, **accepted** (with
|---|---|
| Other tenants' files, deploy history, logs and domains | `host_*` tables in `/var/lib/openvibe-host-api/host.db`; objects in `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>` |
| OpenVibe sessions (Network SSO tokens, other products' cookies) | browsers; Network |
--| The dashboard session and form tokens | `__Host-ov_host_session` cookie; `HOST_FORM_SECRET` |
++| The dashboard session and form tokens | `__Host-ov_host_session` cookie (`server/auth/sso.js:59-115`); `HOST_FORM_SECRET` |
| Host's own credentials (`OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`) | `/etc/openvibe/host.env` (0600) |
| Certificate private keys, DNS provider credentials | `/etc/letsencrypt`, `/root/.secrets` (root only; never read by Host) |
| The host itself: disk, memory and CPU shared with Live, Media, Chat and every database | the one production machine |
@@@ -94,7 +94,8 @@ Tenant pages on `<site>.openvibe.host` are *same-site* with the dashboard on `op
`host-isolation` "the API ignores cookies");
- every dashboard POST needs the dashboard's own `Origin` (or `Referer`) **and** an HMAC form token
(`server/auth/forms.js:21-35`);
--- the dashboard trusts only `__Host-` cookies (`server/auth/sso.js:33`).
++- the dashboard session is only Host's own `__Host-ov_host_session` cookie (`server/auth/viewer.js:99-103`
++ reads it through `server/auth/sso.js:39-41`); the SDK's `ov_token` is display-only for the shared navbar.
**Platform cookies never reach tenants.** Status: fixed by design.
@@@ -168,10 +169,10 @@ escape a directory: there is no directory.
| Threat | Handling | Status |
|---|---|---|
--| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, host-only; tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |
--| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | the server trusts only `__Host-` cookies, which a subdomain cannot set (`sso.js:33`); a planted `ov_token` is ignored (`host-secrets-dashboard` "a planted ov_token cookie … is not a session") | fixed |
--| **Login CSRF / forged dashboard actions from a same-site tenant page** | state cookie `__Host-` + OAuth `state`; every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |
--| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard) | nuisance only | accepted |
++| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, `Path=/`, no `Domain`, SameSite=Lax (`server/auth/sso.js:34-37,61`); tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |
++| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | `ov_token` (openvibe-sdk/sso) is host-only but not HttpOnly, and a tenant page can plant its owner's own valid one with `Domain=openvibe.host; Path=/<longer path>`, which the browser sends first. The dashboard therefore reads only `__Host-ov_host_session` (`server/auth/viewer.js:99-103`), which a subdomain cannot set. Host writes it whenever the SDK writes `ov_token` after a sign-in or refresh, and clears it whenever the SDK clears `ov_token` (`server/auth/sso.js:85-99`). Inside `/auth` the SDK sees the trusted session as `ov_token` (`sso.js:65-69`), so `/auth/me` and the silent sign-in check ignore a planted one too. A planted `ov_refresh` (`Path=/auth/refresh`) renews only a session that already holds the same subject and never creates one: anything else answers 401 and sets no cookie (`sso.js:91,100-103`). Tests: `host-secrets-dashboard` "cookie tossing: a planted, validly signed ov_token …" (alone and next to a real session; forms land in the victim's own account) and "refresh renews the session of the same account only …" | fixed |
++| **Login CSRF / forged dashboard actions from a same-site tenant page** | `/auth/login` stores the OAuth `state` in `__Host-ov_host_flow` and `/auth/callback` requires it to match (`server/auth/sso.js:71-82`), so a planted `ov_oauth_state`/`ov_oauth_verifier` cannot finish the attacker's own sign-in in the victim's browser (`host-secrets-dashboard` "sign-in issues the HttpOnly session cookie …"); every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |
++| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard); likewise a planted *invalid* `ov_refresh` makes the next refresh fail and clear the session | nuisance only | accepted |
| **Cookie bomb** (a tenant sets many large `Domain=openvibe.host` cookies, so the visitor's browser sends oversized headers and nginx answers 400 for `openvibe.host` and every tenant site until the cookies expire or are cleared) | only the Public Suffix List stops a subdomain from setting parent-domain cookies (§5) | **accepted** for alpha; see §5 |
| `ov_token` (the Network access JWT) is JS-readable on `openvibe.host` for the shared navbar | host-only cookie: tenants cannot read it. A dashboard XSS could, and the dashboard CSP allows inline scripts (for the shared chrome). Tenant-controlled values (project names, file paths, log lines, host names) go through `esc()` in `server/render/pages.js`; ids are server-generated and site names match `[a-z0-9-]`. | mitigated; revisit when the shared chrome no longer needs inline script |
| Clickjacking the dashboard | `frame-ancestors 'none'`, `X-Frame-Options: DENY` (`server/app.js:47-60`) | fixed |
ddiff --git a/server/app.js b/server/app.js
iindex 71fadb7..0a456df 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -35,6 +35,7 @@ const { createDomains } = require('./domain/domains');
const { createSiteConfig } = require('./domain/site-config');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
++const { createHostSession } = require('./auth/sso');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
const { createApi } = require('./http/api');
@@@ -210,7 +211,7 @@ async function createApp(opts = {}) {
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
-- app.use('/auth', auth.router(express));
++ app.use('/auth', createHostSession({ auth, config }).router(express));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
// ── Static assets (content-hashed ?v= → immutable) ──────
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..132a3aa
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,118 @@
++'use strict';
++
++/**
++ * The dashboard's own session cookie, around the openvibe-sdk/sso /auth router.
++ *
++ * openvibe-sdk/sso keeps the session in `ov_token`: host-only, not HttpOnly (the shared navbar reads
++ * it). Tenant sites are served at <site>.openvibe.host, the same site as the dashboard, so a tenant
++ * page can plant `ov_token=<its owner's own valid token>; Domain=openvibe.host; Path=/<longer path>`
++ * and the browser sends it ahead of the real one (cookie tossing). Host therefore trusts only its own
++ * cookie, which no subdomain can set or overwrite:
++ *
++ * __Host-ov_host_session HttpOnly, Secure, Path=/, no Domain, SameSite=Lax; the verified access
++ * token, written whenever the SDK writes ov_token after a sign-in
++ * (callback, FedCM) or a refresh, and cleared whenever it clears ov_token
++ * (sign-out, a refresh the Network refuses).
++ * __Host-ov_host_flow the OAuth state of a sign-in started here; /auth/callback requires it,
++ * so a planted ov_oauth_state/ov_oauth_verifier cannot complete the
++ * attacker's own sign-in in the victim's browser.
++ *
++ * ov_refresh (Path=/auth) can be planted too: a refresh renews the session only for the subject the
++ * session already holds, and never creates one. Inside /auth the SDK sees the trusted session as
++ * ov_token (/auth/me and the silent sign-in check never read a planted one). ov_token stays
++ * display-only for the shared navbar.
++ *
++ * Development (COOKIE_SECURE=false, plain http): browsers refuse __Host- cookies without Secure, so
++ * the names drop the prefix (ov_host_session, ov_host_flow) and are not Secure.
++ */
++const crypto = require('crypto');
++const { COOKIES, decodeJwtPayload, parseCookies } = require('openvibe-sdk/sso');
++
++const SESSION_MAX_AGE = 30 * 24 * 3600e3; // the SDK's ov_refresh lifetime: a refresh always finds the session it renews
++const FLOW_MAX_AGE = 10 * 60e3;
++
++function cookieNames(config) {
++ const prefix = config.cookies.secure ? '__Host-' : '';
++ return { session: `${prefix}ov_host_session`, flow: `${prefix}ov_host_flow` };
++}
++
++/** The session token this browser holds on the dashboard, or null (never ov_token). */
++function sessionToken(config, req) {
++ return parseCookies(req)[cookieNames(config).session] || null;
++}
++
++const subjectOf = (token) => { const c = token ? decodeJwtPayload(token) : null; return c && typeof c.subject_id === 'string' ? c.subject_id : null; };
++
++function sameString(a, b) {
++ const x = Buffer.from(String(a || '')), y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++/** The state of a redirect to the Network's authorize endpoint, or null. */
++function authorizeState(url) {
++ try {
++ const u = new URL(String(url));
++ return u.pathname.endsWith('/oauth/authorize') ? u.searchParams.get('state') : null;
++ } catch { return null; }
++}
++
++function createHostSession({ auth, config }) {
++ const names = cookieNames(config);
++ const opts = (maxAge) => ({ httpOnly: true, secure: config.cookies.secure, sameSite: 'lax', path: '/', ...(maxAge ? { maxAge } : {}) });
++
++ function guard(req, res, next) {
++ const cookies = parseCookies(req);
++ const current = cookies[names.session] || null;
++ // The SDK handlers read ov_token from req.cookies: give them the trusted session instead.
++ if (req.cookies && typeof req.cookies === 'object') {
++ if (current) req.cookies[COOKIES.access] = current; else delete req.cookies[COOKIES.access];
++ }
++
++ if (req.path === '/callback' && !(req.query && req.query.error)) {
++ res.clearCookie(names.flow, opts());
++ if (!sameString(req.query && req.query.state, cookies[names.flow])) return res.status(400).send('OAuth state mismatch. Please try signing in again.');
++ }
++ if (req.path === '/login') {
++ const redirect = res.redirect.bind(res);
++ res.redirect = (...args) => {
++ const state = authorizeState(args[args.length - 1]);
++ if (state) res.cookie(names.flow, state, opts(FLOW_MAX_AGE));
++ return redirect(...args);
++ };
++ }
++
++ const setCookie = res.cookie.bind(res);
++ const clearCookie = res.clearCookie.bind(res);
++ let refused = false;
++ res.cookie = (name, value, o) => {
++ if (refused && name === COOKIES.refresh) return res;
++ if (name === COOKIES.access) {
++ // A refresh renews this browser's session; a planted ov_refresh of another account is refused.
++ if (req.path === '/refresh' && (!subjectOf(current) || subjectOf(current) !== subjectOf(value))) { refused = true; return res; }
++ setCookie(names.session, value, opts(SESSION_MAX_AGE));
++ }
++ return setCookie(name, value, o);
++ };
++ res.clearCookie = (name, o) => {
++ if (name === COOKIES.access) clearCookie(names.session, opts());
++ return clearCookie(name, o);
++ };
++ if (req.path === '/refresh') {
++ const json = res.json.bind(res);
++ res.json = (body) => (refused ? (res.status(401), json({ error: 'The session ended. Please sign in again.' })) : json(body));
++ }
++ next();
++ }
++
++ /** The /auth router: Host's guard, then the SDK's handlers. */
++ function router(express) {
++ const r = express.Router();
++ r.use(guard);
++ r.use(auth.router(express));
++ return r;
++ }
++
++ return { names, router };
++}
++
++module.exports = { createHostSession, cookieNames, sessionToken };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex e482a46..639efbe 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -6,7 +6,8 @@
* { kind: 'anonymous' }
* { kind: 'user', subject: 'usr_…', staff, user }
* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).
-- * Dashboard: the ov_token session cookie (openvibe-sdk/sso; the shared navbar reads the same cookie).
++ * Dashboard: only Host's own __Host-ov_host_session cookie (./sso.js). ov_token (openvibe-sdk/sso)
++ * is display-only for the shared navbar: a tenant page on <site>.openvibe.host can plant one.
* staff = the contracts staff map's staff.site.configure (ADR-022):
* quotas, takedowns, and maintaining or deleting any project (never deploying to it).
* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
@@@ -19,9 +20,10 @@
* never downgraded to anonymous.
*/
const contracts = require('openvibe-contracts');
--const { claimsToUser, decodeJwtPayload, parseCookies, COOKIES } = require('openvibe-sdk/sso');
++const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
const { verifyServiceToken } = require('openvibe-sdk/auth');
const { checkCapability } = require('./capabilities');
++const { sessionToken } = require('./sso');
const { ids, http, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
@@@ -95,7 +97,7 @@ function createViewerResolver({ auth, config, log = console }) {
}
async function resolveDashboard(req) {
-- const token = parseCookies(req)[COOKIES.access] || null;
++ const token = sessionToken(config, req);
if (!token) return ANONYMOUS;
return (await fromUserToken(token)) || ANONYMOUS;
}
ddiff --git a/test/host-secrets-dashboard.test.js b/test/host-secrets-dashboard.test.js
iindex 073dcd6..a8a41d4 100644
---- a/test/host-secrets-dashboard.test.js
++++ b/test/host-secrets-dashboard.test.js
@@@ -8,6 +8,7 @@ const assert = require('assert');
const { boot, check, done, SECRET } = require('./stageb/boot');
const { tarball } = require('./stageb/tar');
const { csrfToken } = require('../server/auth/forms');
++const { cookieNames } = require('../server/auth/sso');
const ORIGIN = 'https://openvibe.host';
@@@ -81,8 +82,8 @@ const ORIGIN = 'https://openvibe.host';
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
-- await check('the dashboard session is the verified ov_token cookie; a forged ov_token is not a session', async () => {
-- // openvibe-sdk/sso keeps the session in the ov_token cookie (the shared navbar reads the same one).
++ await check('the dashboard session is Host\'s own cookie; a forged ov_token is not a session', async () => {
++ // openvibe-sdk/sso keeps its session in ov_token (the shared navbar reads it); Host trusts only its own cookie.
const forged = 'not.a.jwt';
const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });
assert.match(r.text, /Sign in with OpenVibe/);
@@@ -93,6 +94,101 @@ const ORIGIN = 'https://openvibe.host';
assert.strictEqual(api.status, 401);
});
++ const SESSION = cookieNames(t.config).session; // ov_host_session here (COOKIE_SECURE=false); __Host- in production
++ const mallory = t.user('mallory');
++ const malloryProjects = async () => (await t.api('GET', '/api/v1/projects', { as: mallory })).json().projects.length;
++ const setCookies = (r) => [].concat(r.headers['set-cookie'] || []);
++ const cookieValue = (r, name) => { const c = setCookies(r).find((x) => x.startsWith(`${name}=`)); return c === undefined ? undefined : decodeURIComponent(c.slice(name.length + 1).split(';')[0]); };
++
++ await check('cookie tossing: a planted, validly signed ov_token of another user is not a session, alone or next to a real one', async () => {
++ // A tenant page on <site>.openvibe.host can set ov_token=<its owner's token>; Domain=openvibe.host; Path=/<longer path>:
++ // the browser sends it first. It verifies, and it is still ignored.
++ const planted = `ov_token=${t.network.userToken(mallory)}`;
++ const alone = await t.api('GET', '/', { headers: { cookie: planted } });
++ assert.match(alone.text, /Sign in with OpenVibe/);
++ assert.strictEqual((await t.api('GET', '/auth/me', { headers: { cookie: planted } })).status, 401);
++ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: mallory.subject });
++ const post = await t.api('POST', '/projects', { form: { csrf, name: 'Tossed', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });
++ assert.ok(!/\/projects\/prj_/.test(post.headers.location || ''), post.text);
++ assert.strictEqual(await malloryProjects(), 0);
++
++ const both = await t.api('GET', '/', { session: alice, headers: { cookie: planted } });
++ assert.match(both.text, /Your projects/);
++ assert.ok(!/mallory/.test(both.text), 'the page is alice\'s, not mallory\'s');
++ const me = await t.api('GET', '/auth/me', { session: alice, headers: { cookie: planted } });
++ assert.strictEqual(me.json().user.subject_id, alice.subject);
++ const csrfA = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
++ const asAlice = await t.api('POST', '/projects', { session: alice, form: { csrf: csrfA, name: 'Tossed but mine', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });
++ assert.strictEqual(asAlice.status, 303, asAlice.text);
++ assert.strictEqual(await malloryProjects(), 0, 'nothing lands in mallory\'s account');
++ const mine = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects;
++ assert.ok(mine.some((p) => p.name === 'Tossed but mine'));
++ for (const p of mine.filter((x) => x.name === 'Tossed but mine')) await t.api('DELETE', `/api/v1/projects/${p.id}`, { as: alice });
++ });
++
++ await check('sign-in issues the HttpOnly session cookie; a planted OAuth state cannot finish another account\'s sign-in', async () => {
++ const login = await t.api('GET', '/auth/login');
++ assert.strictEqual(login.status, 302);
++ const state = new URL(login.headers.location).searchParams.get('state');
++ assert.strictEqual(cookieValue(login, 'ov_host_flow'), state);
++ const flow = `ov_host_flow=${state}; ov_oauth_state=${state}; ov_oauth_verifier=${cookieValue(login, 'ov_oauth_verifier')}`;
++ const cb = await t.api('GET', `/auth/callback?code=${t.network.issueCode(alice)}&state=${state}`, { headers: { cookie: flow } });
++ assert.strictEqual(cb.status, 302, cb.text);
++ const set = setCookies(cb).find((c) => c.startsWith(`${SESSION}=`));
++ assert.match(set, /; Path=\/;/);
++ assert.match(set, /; HttpOnly/);
++ assert.match(set, /; SameSite=Lax/);
++ assert.ok(!/Domain=/i.test(set), set);
++ const dash = await t.api('GET', '/', { headers: { cookie: `${SESSION}=${cookieValue(cb, SESSION)}` } });
++ assert.match(dash.text, /Your projects/);
++
++ // Mallory starts a sign-in of her own, plants her state and verifier (Path=/auth/callback) and sends alice to the callback.
++ const theirs = await t.api('GET', '/auth/login');
++ const s2 = new URL(theirs.headers.location).searchParams.get('state');
++ const tossed = `ov_oauth_state=${s2}; ov_oauth_verifier=${cookieValue(theirs, 'ov_oauth_verifier')}`;
++ for (const cookie of [tossed, `${tossed}; ov_host_flow=${state}`]) {
++ const r = await t.api('GET', `/auth/callback?code=${t.network.issueCode(mallory)}&state=${s2}`, { headers: { cookie } });
++ assert.strictEqual(r.status, 400, r.text);
++ assert.strictEqual(cookieValue(r, SESSION), undefined);
++ }
++ });
++
++ await check('refresh renews the session of the same account only; sign-out clears it', async () => {
++ const session = `${SESSION}=${t.network.userToken(alice)}`;
++ const ok = await t.api('POST', '/auth/refresh', { headers: { cookie: `${session}; ov_refresh=${t.network.refreshToken(alice)}` } });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(require('openvibe-sdk/sso').decodeJwtPayload(cookieValue(ok, SESSION)).subject_id, alice.subject);
++ // A planted ov_refresh of mallory's (Path=/auth/refresh), next to alice's session or alone, never becomes a session.
++ for (const cookie of [`${session}; ov_refresh=${t.network.refreshToken(mallory)}`, `ov_refresh=${t.network.refreshToken(mallory)}`]) {
++ const r = await t.api('POST', '/auth/refresh', { headers: { cookie } });
++ assert.strictEqual(r.status, 401, r.text);
++ assert.strictEqual(cookieValue(r, SESSION), undefined);
++ assert.strictEqual(cookieValue(r, 'ov_token'), undefined);
++ assert.strictEqual(cookieValue(r, 'ov_refresh'), undefined);
++ }
++ const out = await t.api('GET', '/auth/logout', { headers: { cookie: session } });
++ assert.strictEqual(out.status, 302);
++ assert.strictEqual(cookieValue(out, SESSION), '');
++ assert.match(setCookies(out).find((c) => c.startsWith(`${SESSION}=`)), /Expires=Thu, 01 Jan 1970/);
++ });
++
++ await check('in production the session cookie is __Host- prefixed and Secure', async () => {
++ assert.deepStrictEqual(cookieNames({ cookies: { secure: true } }), { session: '__Host-ov_host_session', flow: '__Host-ov_host_flow' });
++ // The guard around a stand-in SDK router whose callback writes ov_token, as setSession does.
++ const express = require('express');
++ const app = express();
++ app.use(require('cookie-parser')());
++ const auth = { router: (ex) => ex.Router().get('/callback', (_req, res) => { res.cookie('ov_token', 'tok', {}); res.send('ok'); }) };
++ app.use('/auth', require('../server/auth/sso').createHostSession({ auth, config: { cookies: { secure: true } } }).router(express));
++ const srv = await new Promise((resolve) => { const x = app.listen(0, '127.0.0.1', () => resolve(x)); });
++ try {
++ const r = await fetch(`http://127.0.0.1:${srv.address().port}/auth/callback?state=s1`, { headers: { cookie: '__Host-ov_host_flow=s1' } });
++ assert.strictEqual(r.status, 200);
++ const set = r.headers.getSetCookie().find((c) => c.startsWith('__Host-ov_host_session='));
++ assert.match(set, /^__Host-ov_host_session=tok; Max-Age=\d+; Path=\/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax$/);
++ } finally { await new Promise((resolve) => srv.close(resolve)); }
++ });
++
let projectId, siteId;
await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {
const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
iindex 949270b..6744fc3 100644
---- a/test/stageb/boot.js
++++ b/test/stageb/boot.js
@@@ -15,6 +15,7 @@ const path = require('path');
const http = require('http');
const { startNetwork } = require('./mocks');
const { site: siteTar } = require('./tar');
++const { cookieNames } = require('../../server/auth/sso');
// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own
// (test/actor-limits.test.js), so they are off here unless a test turns them on.
@@@ -84,7 +85,8 @@ async function boot(opts = {}) {
if (o.host !== undefined) headers.host = o.host;
if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
-- if (o.session) headers.cookie = [headers.cookie, `ov_token=${network.userToken(o.session)}`].filter(Boolean).join('; ');
++ // A dashboard session is Host's own cookie (server/auth/sso.js); ov_token is display-only.
++ if (o.session) headers.cookie = [headers.cookie, `${cookieNames(t.config).session}=${network.userToken(o.session)}`].filter(Boolean).join('; ');
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
ddiff --git a/test/stageb/mocks.js b/test/stageb/mocks.js
iindex b9285cc..853917e 100644
---- a/test/stageb/mocks.js
++++ b/test/stageb/mocks.js
@@@ -1,7 +1,8 @@
'use strict';
/**
* In-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS and /oauth/token
-- * (client_credentials → service tokens with the requested scope as capabilities).
++ * (client_credentials → service tokens with the requested scope as capabilities; authorization_code and
++ * refresh_token → user tokens for issueCode()/refreshToken()).
* userToken()/serviceToken()/appToken() mint what browsers, services and apps present to Host.
*/
const http = require('http');
@@@ -42,6 +43,13 @@ async function startNetwork() {
const cap = String(scope || '').split(/\s+/).filter(Boolean);
return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
}
++ // Browser sign-in: a code from issueCode(), a refresh token from refreshToken() (single use, rotated).
++ const grant = body.grant_type === 'authorization_code' ? grants.get(`code:${body.code}`) : body.grant_type === 'refresh_token' ? grants.get(`rt:${body.refresh_token}`) : undefined;
++ if (grant === undefined && ['authorization_code', 'refresh_token'].includes(body.grant_type)) return json(400, { error: 'invalid_grant' });
++ if (grant) {
++ grants.delete(body.grant_type === 'authorization_code' ? `code:${body.code}` : `rt:${body.refresh_token}`);
++ return json(200, { access_token: userToken(grant), refresh_token: refreshToken(grant), token_type: 'Bearer', expires_in: 3600 });
++ }
return json(400, { error: 'unsupported_grant_type' });
}
return json(404, { error: 'not found' });
@@@ -50,6 +58,9 @@ async function startNetwork() {
function sign({ sub, actor_type = 'service', aud, cap, ...extra }) {
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);
}
++ const grants = new Map();
++ const issueCode = (u) => { const c = crypto.randomBytes(12).toString('hex'); grants.set(`code:${c}`, u); return c; };
++ const refreshToken = (u) => { const r = crypto.randomBytes(12).toString('hex'); grants.set(`rt:${r}`, u); return r; };
function addUser(username, extra = {}) {
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
}
@@@ -67,7 +78,7 @@ async function startNetwork() {
function appToken(appId, cap, extra = {}) {
return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', env: 'production', ...extra });
}
-- return { ...srv, publicPem, addUser, userToken, fedcmAssertion, serviceToken, appToken, sign };
++ return { ...srv, publicPem, addUser, userToken, issueCode, refreshToken, fedcmAssertion, serviceToken, appToken, sign };
}
module.exports = { startNetwork, listen };