openvibers/openvibe.network Commits

Fix silent sign-in on first visits (Network) (#664)

OP OpenVibers committed 5 days ago · 7b3a05bab1 · 8 files, +215 −3

* Fix silent sign-in on first visits (network)

* Preserve provisioned service clients when registering browser callbacks

* Merge current main and preserve Link navbar sign-in

Changes

ddiff --git a/docs/silent-signin-audit-2026-11-08.md b/docs/silent-signin-audit-2026-11-08.md
nnew file mode 100644
iindex 00000000..770e3b29
---- /dev/null
++++ b/docs/silent-signin-audit-2026-11-08.md
@@@ -0,0 +1,70 @@
++# Silent sign-in audit — 2026-11-08 UTC
++
++55 entries from the live Network frame nav, plus the reported openvibe.link. Each entry used a fresh Chrome context with only openvibe.network ov_token/ov_sso cookies from the probe login. A normal Chrome user agent avoids the intentional crawler exclusion; viewport 390×844. No target-site cookies, local storage or pre-granted FedCM permission. CDP Network.setCookieControls explicitly enabled third-party-cookie restrictions and disabled metadata/heuristic exceptions; recorded blocked-cookie reasons confirm enforcement. Each home was observed for five seconds after DOMContentLoaded. Success means a signed-in navbar/auth event. Stored traces strip query strings and cookie values.
++
++Before: **3/56 signed in with third-party cookies blocked**, **46/56 with cookies allowed**. Network, Inventory and Play pass because the latter two remain same-site with Network. The report/ai entry is a separate nav entry on the Report origin.
++
++Browser substitution: serving the proposed Shared navbar plus the Live app and Bot setting to the browser yielded **49/55 nav entries signed in with cookies blocked**. This exercises production OAuth for those entries without deploying the patches. Run, Watch, Homes, Events, Restream and Search still need their backend/config patches; Link was not counted in that substitution pass. Local tests cover the backend changes, including signed-token callbacks for new SDK routers. Guest replays on Live, Community, Chat and Bot each returned home after one quiet round trip with no sign-in form. **No PR has been merged or deployed.**
++
++| Site | Silently before (blocked / allowed) | Cause | PR |
++|---|---|---|---|
++| https://openvibe.live/ | No / Yes | Live’s own iframe/FedCM check depends on third-party cookies | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Live#697](https://github.com/OpenVibers/OpenVibe.Live/pull/697) |
++| https://openvibe.community/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.chat/ | No / No | Iframe blocked by frame-src CSP; cookie dependence as well | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.video/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.pics/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.download/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.tools/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.bot/ | No / No | No silentLogin navbar setting | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Bot#337](https://github.com/OpenVibers/OpenVibe.Bot/pull/337) |
++| https://openvibe.space/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.news/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.tips/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.vip/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.games/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.blog/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.coupons/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.deals/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.trade/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.exchange/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.report/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.report/ai | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.wiki/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.actor/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.media/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.design/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.network/ | Yes / Yes | Passed | — |
++| https://openvibe.work/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.domains/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.today/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.host/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.fun/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://open.shoes/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.reviews/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.casino/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.tours/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.meme/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.vision/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.cam/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.services/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.surf/ | No / No | Iframe blocked by frame-src CSP; cookie dependence as well | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.zone/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://connect.openvibe.website/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.help/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.codes/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.quest/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.food/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.run/ | No / No | No browser SSO routes/nav configuration; callback not registered | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Run#205](https://github.com/OpenVibers/OpenVibe.Run/pull/205), [OpenVibe.Network#664](https://github.com/OpenVibers/OpenVibe.Network/pull/664) |
++| https://openvibe.watch/ | No / No | No silentLogin navbar setting; OAuth route did not request prompt=none or return a silent guest quietly | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Watch#240](https://github.com/OpenVibers/OpenVibe.Watch/pull/240) |
++| https://openvibe.rent/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.homes/ | No / No | Navbar sends sign-in to Rent, leaving its cookie on another origin; Homes callback not registered | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Rent#243](https://github.com/OpenVibers/OpenVibe.Rent/pull/243), [OpenVibe.Network#664](https://github.com/OpenVibers/OpenVibe.Network/pull/664) |
++| https://openvibe.events/ | No / No | No browser SSO routes/nav configuration; callback not registered; nginx page fallback rejects auth POSTs | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Events#265](https://github.com/OpenVibers/OpenVibe.Events/pull/265), [OpenVibe.Network#664](https://github.com/OpenVibers/OpenVibe.Network/pull/664) |
++| https://inventory.openvibe.network/ | Yes / Yes | Passed | — |
++| https://play.openvibe.network/ | Yes / Yes | Passed | — |
++| https://openre.stream/ | No / No | No silentLogin navbar setting; OAuth route did not request prompt=none or preserve the silent guest return | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenRestream#236](https://github.com/OpenVibers/OpenRestream/pull/236) |
++| https://search.openvibe.network/ | No / No | No browser SSO routes/nav configuration; callback not registered | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Search#245](https://github.com/OpenVibers/OpenVibe.Search/pull/245), [OpenVibe.Network#664](https://github.com/OpenVibers/OpenVibe.Network/pull/664) |
++| https://openvibe.email/ | No / Yes | Shared iframe cannot see Network’s cookie; first-use FedCM is not silent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343) |
++| https://openvibe.link/ | No / No | No local web auth/session routes or navbar configuration; link-web OAuth client absent | [OpenVibe.Shared#343](https://github.com/OpenVibers/OpenVibe.Shared/pull/343), [OpenVibe.Network#664](https://github.com/OpenVibers/OpenVibe.Network/pull/664) |
++
++## Rollout after review
++
++All ten PRs remain unmerged for Opus security review. Release and pin Shared #343 after review, apply the Network callback registrations with their corresponding site routes, deploy, and rerun this browser audit. The browser substitutions described above are test evidence, not a production deployment. See each linked PR for tests and current CI.
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 344730eb..56fe548a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -94,6 +94,8 @@ async function seedDb(db, { log = console } = {}) {
// The account hub's own origin (plan OpenVibe_Seven_Domains_Plan §4): a first-party confidential client
// with PKCE, so openvibe.my has its own host-only session instead of reading a .openvibe.network cookie.
{ client_id: 'my', name: 'OpenVibe.My', redirect_uris: ['https://openvibe.my/auth/callback'] },
++ // Link's website is confidential; keep it separate from the public mobile `link` client.
++ { client_id: 'link-web', name: 'OpenVibe Link website', redirect_uris: ['https://openvibe.link/auth/callback'] },
{ client_id: 'tools', name: 'OpenVibe.Tools', redirect_uris: ['https://openvibe.tools/auth/callback'] },
{ client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },
{ client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },
@@@ -166,7 +168,7 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'work', name: 'OpenVibe.Work', redirect_uris: ['https://openvibe.work/auth/callback'] },
// The shared quest log, listings people post, and Watch's public site (plan T18 step 8, T19).
{ client_id: 'quest', name: 'OpenVibe.Quest', redirect_uris: ['https://openvibe.quest/auth/callback'] },
-- { client_id: 'rent', name: 'OpenVibe.Rent', redirect_uris: ['https://openvibe.rent/auth/callback'] },
++ { client_id: 'rent', name: 'OpenVibe.Rent', redirect_uris: ['https://openvibe.rent/auth/callback', 'https://openvibe.homes/auth/callback'] },
{ client_id: 'watch', name: 'OpenVibe.Watch', redirect_uris: ['https://openvibe.watch/auth/callback'] },
// openvibe.download (a private drive) and its sibling brands openvibe.pics and openvibe.video (plan T10 D4).
// openvibe.video signs people in on its own host for playlists (OpenVibe.Video, 2026-10-12), and openvibe.pics
@@@ -240,6 +242,11 @@ async function seedDb(db, { log = console } = {}) {
// (server/setup/service-principal.js create chat), so this adds the URI where the client exists rather than
// creating one.
await mergeRedirectUris(db, 'chat', ['https://openvibe.party/auth/callback'], { log });
++ // These clients are provisioned as services. Add browser callbacks to existing rows without
++ // creating principals or changing the installer's secret/creation contract.
++ await mergeRedirectUris(db, 'run', ['https://openvibe.run/auth/callback'], { log });
++ await mergeRedirectUris(db, 'events', ['https://openvibe.events/auth/callback'], { log });
++ await mergeRedirectUris(db, 'search', ['https://search.openvibe.network/auth/callback'], { log });
if (seededAny) console.log('[DB] New OAuth2 clients seeded; their secrets are in oauth_clients (they are never logged).');
}
mark('oauth clients');
ddiff --git a/server/index.js b/server/index.js
iindex 1bbfed12..d3b04070 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -340,6 +340,7 @@ config._registry = resolvedRegistry;
// paths 301 there; www.openvibe.my 301s to the apex. The route also mounts the hub's own OAuth client
// (PKCE, host-only cookies) at /auth, before Network's sign-in pages, so /my never renders off-origin.
app.use(await require('./my/routes').createMyRoutes({ db, config }));
++app.use(await require('./node/auth').createLinkAuthRoutes({ db, config }));
// ── Analytics Tracking (ADR-021) ──────────────────────────────
// openvibe-shared/analytics on PostgreSQL (server/analytics/network.js; migrations/0002_analytics.sql).
ddiff --git a/server/node/auth.js b/server/node/auth.js
nnew file mode 100644
iindex 00000000..8e22471b
---- /dev/null
++++ b/server/node/auth.js
@@@ -0,0 +1,29 @@
++'use strict';
++// openvibe.link is a separate cookie origin. Its web session uses the SDK's confidential
++// OAuth flow; the existing public `link` client belongs to the installed mobile app.
++const express = require('express');
++const { createSsoClient } = require('openvibe-sdk/sso');
++const { HOST, SITE } = require('./page');
++const { hostOf } = require('./routes');
++
++async function createLinkAuthRoutes({ db, config, log = console } = {}) {
++ const row = await db.prepare('SELECT client_secret FROM oauth_clients WHERE client_id = ?').get('link-web');
++ const client = createSsoClient({
++ site: 'link', siteName: 'OpenVibe Link', baseUrl: SITE,
++ clientId: 'link-web', clientSecret: row?.client_secret || '',
++ redirectUri: `${SITE}/auth/callback`, scope: 'profile theme',
++ networkUrl: config.networkUrl, networkInternalUrl: config.internalUrl,
++ issuer: config.jwt.issuer, audience: ['openvibe.network'],
++ secureCookies: config.nodeEnv === 'production', log,
++ });
++ const auth = client.router(express);
++ const router = express.Router();
++ router.use('/auth', (req, res, next) => {
++ if (hostOf(req) !== HOST) return next();
++ res.set('Cache-Control', 'no-store');
++ return auth(req, res, next);
++ });
++ return router;
++}
++
++module.exports = { createLinkAuthRoutes };
ddiff --git a/server/node/page.js b/server/node/page.js
iindex 7727d0e2..f4d4a48c 100644
---- a/server/node/page.js
++++ b/server/node/page.js
@@@ -218,7 +218,7 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
<script src="/shared/navbar.js" defer></script>
${require('../boost').SCRIPT}
<script src="/shared/kit-web.js" defer></script>
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
</body>
</html>`;
}
ddiff --git a/server/node/routes.js b/server/node/routes.js
iindex 4117bf03..3cd3e6a2 100644
---- a/server/node/routes.js
++++ b/server/node/routes.js
@@@ -87,7 +87,7 @@ function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
// Your machines and pairing are the signed-in account hub's own pages (openvibe.my/machines): it lists every
// paired machine, whether it is online, what it is allowed to do, and pairs a new one. Link links there rather
-- // than keeping a second copy of the session on this host (sign-in stays on Network; plan 2026-11-07 §5).
++ // than keeping a second machines UI here. Link's local OAuth session supplies its navbar identity.
if (path === '/machines' || path === '/pair') {
return res.set('Cache-Control', 'no-store').redirect(302, 'https://openvibe.my/machines');
}
ddiff --git a/test/link-auth.test.js b/test/link-auth.test.js
nnew file mode 100644
iindex 00000000..8a398306
---- /dev/null
++++ b/test/link-auth.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const crypto = require('node:crypto');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { createLinkAuthRoutes } = require('../server/node/auth');
++const { render } = require('../server/node/page');
++const listen = app => new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++const close = s => new Promise(resolve => s.close(resolve));
++const cookieJar = r => (r.headers['set-cookie'] || []).map(c => c.split(';')[0]).join('; ');
++(async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const key = { ...publicKey.export({ format: 'jwk' }), kid: 'link-test', alg: 'RS256', use: 'sig' };
++ let grants = 0;
++ const network = express(); network.use(express.json());
++ network.get('/api/.well-known/jwks', (_req, res) => res.json({ keys: [key] }));
++ network.post('/oauth/token', (req, res) => {
++ assert.equal(req.body.client_id, 'link-web');
++ assert.equal(req.body.client_secret, 'test-only-secret');
++ if (req.body.code !== 'issued-test-code') return res.status(400).json({ error: 'invalid_grant' });
++ assert.equal(req.body.redirect_uri, 'https://openvibe.link/auth/callback');
++ assert.ok(req.body.code_verifier);
++ grants++;
++ res.json({ access_token: jwt.sign({ sub: '12', type: 'person', username: 'probe', is_anon: false }, privateKey,
++ { algorithm: 'RS256', keyid: key.kid, issuer: 'https://openvibe.network', audience: 'openvibe.network', expiresIn: '1h' }), refresh_token: 'test-refresh' });
++ });
++ const ns = await listen(network);
++ const app = express(); app.use(require('cookie-parser')());
++ app.use(await createLinkAuthRoutes({
++ db: { prepare: () => ({ get: async id => { assert.equal(id, 'link-web'); return { client_secret: 'test-only-secret' }; } }) },
++ config: { networkUrl: 'https://openvibe.network', internalUrl: `http://127.0.0.1:${ns.address().port}`, jwt: { issuer: 'https://openvibe.network' }, nodeEnv: 'production' },
++ log: { warn() {}, error() {} },
++ }));
++ const server = await listen(app);
++ const get = (path, cookie = '', host = 'openvibe.link') => new Promise((resolve, reject) => {
++ const req = http.get({ hostname: '127.0.0.1', port: server.address().port, path, headers: { Host: host, Cookie: cookie } }, res => {
++ let body = ''; res.on('data', c => body += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ }); req.on('error', reject);
++ });
++ try {
++ assert.equal((await get('/auth/login', '', 'openvibe.network')).status, 404, 'only Link owns these routes');
++ assert.equal((await get('/auth/login', '', 'evil.openvibe.link')).status, 404, 'no suffix host match');
++ assert.deepEqual(JSON.parse((await get('/auth/me')).body), { user: null });
++ assert.equal(JSON.parse((await get('/auth/health')).body).client, 'ok');
++ const start = await get('/auth/login?silent=1&next=' + encodeURIComponent('https://openvibe.link/?from=nav#install'));
++ const authorize = new URL(start.headers.location);
++ assert.equal(authorize.searchParams.get('prompt'), 'none');
++ assert.equal(authorize.searchParams.get('client_id'), 'link-web');
++ assert.equal(authorize.searchParams.get('redirect_uri'), 'https://openvibe.link/auth/callback');
++ assert.equal(authorize.searchParams.get('code_challenge_method'), 'S256');
++ const state = authorize.searchParams.get('state');
++ const flow = cookieJar(start);
++ const mismatch = await get('/auth/callback?code=issued-test-code&state=wrong', flow);
++ assert.ok(!cookieJar(mismatch).includes('ov_token='), 'wrong state cannot establish a session');
++ assert.equal(grants, 0);
++ const guest = await get('/auth/callback?error=login_required&state=' + state, flow);
++ assert.equal(guest.status, 302);
++ assert.equal(guest.headers.location, 'https://openvibe.link/?from=nav&sso=none#install', 'guests return quietly, preserving the location');
++ const success = await get('/auth/callback?code=issued-test-code&state=' + state, flow);
++ assert.equal(success.status, 302);
++ assert.equal(success.headers.location, 'https://openvibe.link/?from=nav#install');
++ const access = success.headers['set-cookie'].find(c => c.startsWith('ov_token='));
++ assert.ok(access && /Secure/i.test(access) && /SameSite=Lax/i.test(access));
++ assert.ok(!/Domain=/i.test(access), 'host-only session');
++ const me = await get('/auth/me', cookieJar(success));
++ assert.equal(JSON.parse(me.body).user.username, 'probe');
++ assert.equal(me.headers['cache-control'], 'no-store');
++ const external = await get('/auth/login?silent=1&next=https%3A%2F%2Fevil.test%2F');
++ const externalState = new URL(external.headers.location).searchParams.get('state');
++ const fallback = await get('/auth/callback?error=login_required&state='+externalState, cookieJar(external));
++ assert.equal(fallback.headers.location, '/?sso=none', 'off-origin next is refused');
++ const html = render();
++ for (const fragment of ["sessionUrl:'/auth/me'", "silentLogin:'/auth/login?silent=1&next={url}'", "logoutUrl:'/auth/logout?next={url}'"]) assert.ok(html.includes(fragment));
++ console.log('link auth: SDK PKCE, host scope, guest return, session verification and redirect checks passed');
++ } finally { await close(server); await close(ns); }
++})().catch(err => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/web-client-callbacks.test.js b/test/web-client-callbacks.test.js
nnew file mode 100644
iindex 00000000..09ac60eb
---- /dev/null
++++ b/test/web-client-callbacks.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { getDb, seedDb } = require('../server/db/database');
++(async () => {
++ const db = await getDb();
++ const clients = [['run', 'openvibe.run'], ['events', 'openvibe.events'], ['search', 'search.openvibe.network']];
++ for (const [id] of clients) {
++ const existing = await db.prepare('SELECT client_id FROM oauth_clients WHERE client_id = ?').get(id);
++ assert.ok(!existing, 'boot does not provision a service principal');
++ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
++ .run(id, `test-${id}`, id, '["https://existing.example/callback"]');
++ }
++ const log = { log() {}, warn() {}, error() {} };
++ await seedDb(db, { log }); await seedDb(db, { log });
++ for (const [id, host] of clients) {
++ const row = await db.prepare('SELECT client_secret, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
++ assert.equal(row.client_secret, `test-${id}`, 'the service keeps its credential');
++ assert.deepEqual(JSON.parse(row.redirect_uris), ['https://existing.example/callback', `https://${host}/auth/callback`], 'idempotent exact callback union');
++ }
++ const rent = await db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'rent'").get();
++ assert.ok(JSON.parse(rent.redirect_uris).includes('https://openvibe.homes/auth/callback'));
++ const web = await db.prepare("SELECT is_public, redirect_uris FROM oauth_clients WHERE client_id = 'link-web'").get();
++ assert.equal(Number(web.is_public || 0), 0);
++ assert.deepEqual(JSON.parse(web.redirect_uris), ['https://openvibe.link/auth/callback']);
++ const mobile = await db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'link'").get();
++ assert.ok(!JSON.parse(mobile.redirect_uris).includes('https://openvibe.link/auth/callback'), 'mobile client stays separate');
++ console.log('web client callbacks: exact URIs, idempotence and credential preservation passed');
++})().catch(err => { console.error(err); process.exitCode = 1; });