openvibers/openvibe.inventory Commits

Share the verified openvibe.my session with mounted Inventory (#251)

OP OpenVibers committed 3 days ago · c0c2a8bdda · 4 files, +58 −30

Changes

ddiff --git a/README.md b/README.md
iindex effb20e..a285a1f 100644
---- a/README.md
++++ b/README.md
@@@ -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:
ddiff --git a/server/app.js b/server/app.js
iindex 946c4d3..6665a1b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/auth/scoped-session.js b/server/auth/scoped-session.js
iindex 386c331..738773a 100644
---- a/server/auth/scoped-session.js
++++ b/server/auth/scoped-session.js
@@@ -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;
ddiff --git a/test/base-path.test.js b/test/base-path.test.js
iindex 16f91bd..ab4cd88 100644
---- a/test/base-path.test.js
++++ b/test/base-path.test.js
@@@ -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');