openvibers/openvibe.inventory Commits
Share the verified openvibe.my session with mounted Inventory (#251)
Changes
@@@ -264,14 +264,14 @@ Inventory uses `/inventory`, `/inventory/all`, `/inventory/<itd_id>`, `/inventor`INVENTORY_REDIRECT_LEGACY=true` enables permanent redirects for the old page routes only,preserving query strings. Leave it unset until the new deployment is verified.--Sign-in needs Opus review before either PR merges. SDK 0.94 hardcodes cookie names and paths;--`server/auth/scoped-session.js` adapts its Express boundary. Under the prefix, session and--flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.--The hub's cookies at `/` are untouched. Browser page entry makes a top-level--`prompt=none` round trip, so silent sign-in also works with third-party cookies blocked.--Guests return with `sso=none`; Inventory's own sign-out hint prevents automatic re-entry.--The adapter ignores the hub's root session and flow cookies. OAuth still uses SDK PKCE,--state verification, refresh and error recovery. The original host keeps its existing cookies.++`server/auth/scoped-session.js` adapts the SDK's Express boundary. Inventory's own++session, flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.++Mounted requests prefer the hub's root `ov_token`, verified by the same SDK verifier++against Inventory's configured issuer and audience (`openvibe.network` by default).++No audience is added or bypassed. Hub refresh and OAuth flow cookies are never borrowed++or written, and a previous Inventory refresh token cannot replace the hub viewer.++Guests render without an automatic OAuth redirect. Explicit Inventory OAuth still uses++SDK PKCE, state verification and error recovery. The original host is unchanged.After review, deploy in this order:@@@ -197,19 +197,6 @@ async function createApp(opts = {}) {// ── Who is asking (verified offline; refreshed when expired) ──app.use(sso.middleware());-- // A top-level silent authorization sees Network's SameSite cookies even when-- // third-party iframe cookies are blocked. Guests return with sso=none once.-- app.use((req, res, next) => {-- if (req.inventoryScoped && req.method === 'GET' && legacyPage(req.path)-- && String(req.get('accept') || '').includes('text/html')-- && !/bot|crawl|spider|slurp/i.test(String(req.get('user-agent') || ''))-- && req.viewer.kind !== 'user' && req.cookies.ov_sso_hint !== 'guest' && req.query.sso !== 'none') {-- res.set('Cache-Control', 'private, no-store');-- return res.redirect(302, url.path('/auth/login') + '?silent=1&next=' + encodeURIComponent(req.originalUrl));-- }-- next();-- });--// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, skip: (req) => req.path === '/me' || req.path === '/navbar-token', standardHeaders: true, legacyHeaders: false }));@@@ -2,7 +2,9 @@const { urls } = require('../urls');// SDK 0.94 fixes its cookie names and /auth paths. Adapt its Express boundary so--// Inventory never reads or writes the account hub's root session or flow cookies.++// Inventory writes only its own cookies and never borrows hub refresh or flow cookies.++// The mounted pages may read the hub access token: the SSO verifier still requires++// Inventory's configured issuer, audience, expiry and a person session.function scopedSession(config) {const url = urls(config);return (req, res, next) => {@@@ -11,6 +13,13 @@ function scopedSession(config) {req.cookies = Object.fromEntries(Object.entries(incoming).filter(([name]) => name.startsWith('inventory_ov_')).map(([name, value]) => [name.slice('inventory_'.length), value]));++ // One site, one viewer. Do not decode claims or broaden the accepted audience:++ // optionalAuth verifies this token exactly like an Inventory-issued session.++ if (incoming.ov_token) {++ req.cookies.ov_token = incoming.ov_token;++ // A previous Inventory account must not replace the hub viewer on a 401.++ delete req.cookies.ov_refresh;++ }const cookie = res.cookie.bind(res);const clearCookie = res.clearCookie.bind(res);const nameFor = (name) => name.startsWith('ov_') ? `inventory_${name}` : name;@@@ -2,6 +2,7 @@const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const { urls } = require('../server/urls');++const { signJwt } = require('./helpers/mocks');const { DEF_RE } = require('../server/inventory/store');const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');(async () => {@@@ -61,17 +62,48 @@ const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0])assert.notStrictEqual((await t.get(path)).status, 301, path);}});-- await check('browser entry silently checks Network without third-party cookies', async () => {-- const r = await t.get('/inventory', { headers: { accept: 'text/html' } });-- assert.strictEqual(r.status, 302);-- assert.strictEqual(r.headers.get('location'), '/inventory/auth/login?silent=1&next=%2Finventory');-- assert.strictEqual((await t.get('/inventory?sso=none', { headers: { accept: 'text/html' } })).status, 200);-- assert.strictEqual((await t.get('/inventory', { cookie: 'inventory_ov_sso_hint=guest', headers: { accept: 'text/html' } })).status, 200);++ await check('mounted pages share the verified hub viewer without a sign-in hop', async () => {++ const other = t.network.addUser('previous-inventory-owner');++ const hub = `ov_token=${t.network.userToken(user)}`;++ for (const path of ['/inventory', '/inventory/me']) {++ const r = await t.get(path, { cookie: `${hub}; ${t.signIn(other)}`, headers: { accept: 'text/html' } });++ assert.strictEqual(r.status, 200);++ assert.ok(!r.text.includes('Sign in to see yours'));++ assert.strictEqual(r.headers.get('location'), null);++ }++ const me = await t.get('/inventory/auth/me', { cookie: hub });++ assert.strictEqual(me.json().user.username, user.username);++ assert.strictEqual(me.headers.get('set-cookie'), null, 'no second session created');++ const { scopedSession } = require('../server/auth/scoped-session');++ const req = { inventoryScoped: true, cookies: { ov_token: 'hub', ov_refresh: 'hub-refresh', inventory_ov_refresh: 'old-account-refresh' } };++ scopedSession(t.config)(req, { cookie() {}, clearCookie() {} }, () => {});++ assert.strictEqual(req.cookies.ov_refresh, undefined, 'never refresh the hub viewer as a previous Inventory account');++ });++ await check('guests and bots arrive without redirects, even with a stale hint', async () => {++ for (const cookie of ['', 'ov_sso_hint=guest', 'ov_sso_hint=account', 'inventory_ov_sso_hint=guest']) {++ for (const agent of ['Mozilla/5.0', 'Googlebot']) {++ const r = await t.get('/inventory', { cookie, headers: { accept: 'text/html', 'user-agent': agent } });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Sign in to see yours/);++ }++ }++ });++ await check('sharing the hub cookie does not relax session verification', async () => {++ const now = Math.floor(Date.now() / 1000);++ const claims = { iss: t.network.url, sub: user.id, id: user.id, subject_id: user.subject,++ username: user.username, aud: 'openvibe.network', iat: now, exp: now + 300 };++ for (const patch of [{ aud: 'openvibe.my' }, { exp: now - 300 }, { iss: 'https://wrong.example' },++ { typ: 'fedcm' }, { actor_type: 'app', sub: 'app:bad' }]) {++ const token = signJwt({ ...claims, ...patch }, t.network.privatePem);++ const r = await t.get('/inventory/auth/me', { cookie: `ov_token=${token}` });++ assert.strictEqual(r.json().user, null, JSON.stringify(patch));++ }++ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: 'ov_token=invalid' })).json().user, null);});await check('silent PKCE callback uses isolated host-only Inventory cookies', async () => {const hub = `ov_token=${t.network.userToken(user)}; ov_refresh=hub-refresh; ov_oauth_state=hub-state`;-- assert.strictEqual((await t.get('/inventory/auth/me', { cookie: hub })).json().user, null, 'hub session is not an Inventory session');-- const login = await t.get('/inventory/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.my/inventory'), { cookie: hub });++ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: hub })).json().user.username, user.username);++ const login = await t.get('/inventory/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.my/inventory'), { cookie: 'ov_refresh=hub-refresh; ov_oauth_state=hub-state' });const loc = new URL(login.headers.get('location'));assert.strictEqual(loc.searchParams.get('prompt'), 'none');assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://openvibe.my/inventory/auth/callback');
@@ -264,14 +264,14 @@ Inventory uses `/inventory`, `/inventory/all`, `/inventory/<itd_id>`, `/inventor
@@ -264,14 +264,14 @@ Inventory uses `/inventory`, `/inventory/all`, `/inventory/<itd_id>`, `/inventor
`INVENTORY_REDIRECT_LEGACY=true` enables permanent redirects for the old page routes only,
`INVENTORY_REDIRECT_LEGACY=true` enables permanent redirects for the old page routes only,
preserving query strings. Leave it unset until the new deployment is verified.
preserving query strings. Leave it unset until the new deployment is verified.
-Sign-in needs Opus review before either PR merges. SDK 0.94 hardcodes cookie names and paths;
-`server/auth/scoped-session.js` adapts its Express boundary. Under the prefix, session and
-flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.
-The hub's cookies at `/` are untouched. Browser page entry makes a top-level
-`prompt=none` round trip, so silent sign-in also works with third-party cookies blocked.
-Guests return with `sso=none`; Inventory's own sign-out hint prevents automatic re-entry.
-The adapter ignores the hub's root session and flow cookies. OAuth still uses SDK PKCE,
-state verification, refresh and error recovery. The original host keeps its existing cookies.
+`server/auth/scoped-session.js` adapts the SDK's Express boundary. Inventory's own
+session, flow and hint cookies use `inventory_ov_*`, host-only at `/inventory`.
+Mounted requests prefer the hub's root `ov_token`, verified by the same SDK verifier
+against Inventory's configured issuer and audience (`openvibe.network` by default).
+No audience is added or bypassed. Hub refresh and OAuth flow cookies are never borrowed
+or written, and a previous Inventory refresh token cannot replace the hub viewer.
+Guests render without an automatic OAuth redirect. Explicit Inventory OAuth still uses
+SDK PKCE, state verification and error recovery. The original host is unchanged.
After review, deploy in this order:
After review, deploy in this order:
@@ -197,19 +197,6 @@ async function createApp(opts = {}) {
@@ -197,19 +197,6 @@ async function createApp(opts = {}) {
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
app.use(sso.middleware());
app.use(sso.middleware());
- // A top-level silent authorization sees Network's SameSite cookies even when
- // third-party iframe cookies are blocked. Guests return with sso=none once.
- app.use((req, res, next) => {
- if (req.inventoryScoped && req.method === 'GET' && legacyPage(req.path)
- && String(req.get('accept') || '').includes('text/html')
- && !/bot|crawl|spider|slurp/i.test(String(req.get('user-agent') || ''))
- && req.viewer.kind !== 'user' && req.cookies.ov_sso_hint !== 'guest' && req.query.sso !== 'none') {
- res.set('Cache-Control', 'private, no-store');
- return res.redirect(302, url.path('/auth/login') + '?silent=1&next=' + encodeURIComponent(req.originalUrl));
- }
- next();
- });
-
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, skip: (req) => req.path === '/me' || req.path === '/navbar-token', standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, skip: (req) => req.path === '/me' || req.path === '/navbar-token', standardHeaders: true, legacyHeaders: false }));
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
const { urls } = require('../urls');
const { urls } = require('../urls');
// SDK 0.94 fixes its cookie names and /auth paths. Adapt its Express boundary so
// SDK 0.94 fixes its cookie names and /auth paths. Adapt its Express boundary so
-// Inventory never reads or writes the account hub's root session or flow cookies.
+// Inventory writes only its own cookies and never borrows hub refresh or flow cookies.
+// The mounted pages may read the hub access token: the SSO verifier still requires
+// Inventory's configured issuer, audience, expiry and a person session.
function scopedSession(config) {
function scopedSession(config) {
const url = urls(config);
const url = urls(config);
return (req, res, next) => {
return (req, res, next) => {
@@ -11,6 +13,13 @@ function scopedSession(config) {
@@ -11,6 +13,13 @@ function scopedSession(config) {
req.cookies = Object.fromEntries(Object.entries(incoming)
req.cookies = Object.fromEntries(Object.entries(incoming)
.filter(([name]) => name.startsWith('inventory_ov_'))
.filter(([name]) => name.startsWith('inventory_ov_'))
.map(([name, value]) => [name.slice('inventory_'.length), value]));
.map(([name, value]) => [name.slice('inventory_'.length), value]));
+ // One site, one viewer. Do not decode claims or broaden the accepted audience:
+ // optionalAuth verifies this token exactly like an Inventory-issued session.
+ if (incoming.ov_token) {
+ req.cookies.ov_token = incoming.ov_token;
+ // A previous Inventory account must not replace the hub viewer on a 401.
+ delete req.cookies.ov_refresh;
+ }
const cookie = res.cookie.bind(res);
const cookie = res.cookie.bind(res);
const clearCookie = res.clearCookie.bind(res);
const clearCookie = res.clearCookie.bind(res);
const nameFor = (name) => name.startsWith('ov_') ? `inventory_${name}` : name;
const nameFor = (name) => name.startsWith('ov_') ? `inventory_${name}` : name;
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { urls } = require('../server/urls');
const { urls } = require('../server/urls');
+const { signJwt } = require('./helpers/mocks');
const { DEF_RE } = require('../server/inventory/store');
const { DEF_RE } = require('../server/inventory/store');
const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
(async () => {
(async () => {
@@ -61,17 +62,48 @@ const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0])
@@ -61,17 +62,48 @@ const cookieHeader = (r) => r.headers.getSetCookie().map((c) => c.split(';')[0])
assert.notStrictEqual((await t.get(path)).status, 301, path);
assert.notStrictEqual((await t.get(path)).status, 301, path);
}
}
});
});
- await check('browser entry silently checks Network without third-party cookies', async () => {
- const r = await t.get('/inventory', { headers: { accept: 'text/html' } });
- assert.strictEqual(r.status, 302);
- assert.strictEqual(r.headers.get('location'), '/inventory/auth/login?silent=1&next=%2Finventory');
- assert.strictEqual((await t.get('/inventory?sso=none', { headers: { accept: 'text/html' } })).status, 200);
- assert.strictEqual((await t.get('/inventory', { cookie: 'inventory_ov_sso_hint=guest', headers: { accept: 'text/html' } })).status, 200);
+ await check('mounted pages share the verified hub viewer without a sign-in hop', async () => {
+ const other = t.network.addUser('previous-inventory-owner');
+ const hub = `ov_token=${t.network.userToken(user)}`;
+ for (const path of ['/inventory', '/inventory/me']) {
+ const r = await t.get(path, { cookie: `${hub}; ${t.signIn(other)}`, headers: { accept: 'text/html' } });
+ assert.strictEqual(r.status, 200);
+ assert.ok(!r.text.includes('Sign in to see yours'));
+ assert.strictEqual(r.headers.get('location'), null);
+ }
+ const me = await t.get('/inventory/auth/me', { cookie: hub });
+ assert.strictEqual(me.json().user.username, user.username);
+ assert.strictEqual(me.headers.get('set-cookie'), null, 'no second session created');
+ const { scopedSession } = require('../server/auth/scoped-session');
+ const req = { inventoryScoped: true, cookies: { ov_token: 'hub', ov_refresh: 'hub-refresh', inventory_ov_refresh: 'old-account-refresh' } };
+ scopedSession(t.config)(req, { cookie() {}, clearCookie() {} }, () => {});
+ assert.strictEqual(req.cookies.ov_refresh, undefined, 'never refresh the hub viewer as a previous Inventory account');
+ });
+ await check('guests and bots arrive without redirects, even with a stale hint', async () => {
+ for (const cookie of ['', 'ov_sso_hint=guest', 'ov_sso_hint=account', 'inventory_ov_sso_hint=guest']) {
+ for (const agent of ['Mozilla/5.0', 'Googlebot']) {
+ const r = await t.get('/inventory', { cookie, headers: { accept: 'text/html', 'user-agent': agent } });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Sign in to see yours/);
+ }
+ }
+ });
+ await check('sharing the hub cookie does not relax session verification', async () => {
+ const now = Math.floor(Date.now() / 1000);
+ const claims = { iss: t.network.url, sub: user.id, id: user.id, subject_id: user.subject,
+ username: user.username, aud: 'openvibe.network', iat: now, exp: now + 300 };
+ for (const patch of [{ aud: 'openvibe.my' }, { exp: now - 300 }, { iss: 'https://wrong.example' },
+ { typ: 'fedcm' }, { actor_type: 'app', sub: 'app:bad' }]) {
+ const token = signJwt({ ...claims, ...patch }, t.network.privatePem);
+ const r = await t.get('/inventory/auth/me', { cookie: `ov_token=${token}` });
+ assert.strictEqual(r.json().user, null, JSON.stringify(patch));
+ }
+ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: 'ov_token=invalid' })).json().user, null);
});
});
await check('silent PKCE callback uses isolated host-only Inventory cookies', async () => {
await check('silent PKCE callback uses isolated host-only Inventory cookies', async () => {
const hub = `ov_token=${t.network.userToken(user)}; ov_refresh=hub-refresh; ov_oauth_state=hub-state`;
const hub = `ov_token=${t.network.userToken(user)}; ov_refresh=hub-refresh; ov_oauth_state=hub-state`;
- assert.strictEqual((await t.get('/inventory/auth/me', { cookie: hub })).json().user, null, 'hub session is not an Inventory session');
- const login = await t.get('/inventory/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.my/inventory'), { cookie: hub });
+ assert.strictEqual((await t.get('/inventory/auth/me', { cookie: hub })).json().user.username, user.username);
+ const login = await t.get('/inventory/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.my/inventory'), { cookie: 'ov_refresh=hub-refresh; ov_oauth_state=hub-state' });
const loc = new URL(login.headers.get('location'));
const loc = new URL(login.headers.get('location'));
assert.strictEqual(loc.searchParams.get('prompt'), 'none');
assert.strictEqual(loc.searchParams.get('prompt'), 'none');
assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://openvibe.my/inventory/auth/callback');
assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://openvibe.my/inventory/auth/callback');