openvibers/openvibe.network Commits
FedCM identity provider, link hand-off between sites, jwt-bearer exchange
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js",++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -38,6 +38,10 @@// but this browser has signed in to the network before (ov_sso_hint), try one silent// prompt=none round trip per tab so a session on one site becomes a session on all.silentLogin: null,++ // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first++ // time and re-authenticates silently afterwards; 'silent' only re-authenticates.++ fedcm: 'optional',++ fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)};const _runtimeMenu = { before: [], after: [] };let _runtimeLinks = null;@@@ -790,6 +794,23 @@* never for bots. The site's login route turns silent=1 into prompt=none and comes straight* back on error=login_required, so a signed-out visitor sees one quick redirect at most.*/++ let _ssoClientLoading = null;++ function loadSsoClient() {++ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);++ if (_ssoClientLoading) return _ssoClientLoading;++ _ssoClientLoading = new Promise((resolve) => {++ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;++ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);++ document.head.appendChild(sc);++ });++ return _ssoClientLoading;++ }++++ /** Signed in here: cross-site links carry the session along (see sso-client.js). */++ function enableHandoff() {++ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });++ }++function silentLoginNow() {const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));location.replace(url);@@@ -817,7 +838,18 @@sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));} catch { return false; }if (hint === 'account') return silentLoginNow();-- checkNetworkSession().then((state) => { if (state && state.signedIn) silentLoginNow(); });++ checkNetworkSession().then((state) => {++ if (state && state.signedIn) return silentLoginNow();++ // No answer or "not signed in" — either a guest, or a browser that keeps the network's++ // cookie away from iframes. FedCM asks the browser itself; the network's login status++ // makes it a no-op for guests, a native chip (then silent re-auth) for signed-in users.++ if (_config.fedcm === false) return;++ loadSsoClient().then(async (sso) => {++ if (!sso || !sso.fedcmAvailable()) return;++ const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' });++ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); }++ });++ });return false;}@@@ -868,6 +900,7 @@if (session.token) _config.token = session.token;render();recordHistory();++ enableHandoff();} else {maybeSilentLogin();}@@@ -1060,6 +1093,8 @@clearAuthState();_config.user = null;_config.token = null;++ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }if (onToolsDomain()) {// The gateway also clears the Domain=.openvibe.tools cookie// and the httpOnly refresh cookie, then sends us back here.@@@ -1101,7 +1136,7 @@// the shared SSO state (ov_token cookie / localStorage / optional// sessionUrl) and the navbar re-renders when it arrives.if (!_config.user) refreshAuthState();-- else recordHistory();++ else { recordHistory(); enableHandoff(); }return el;},nnew file mode 100644@@@ -0,0 +1,117 @@++/**++ * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in++ * across separate domains. Loaded lazily by navbar.js; Live loads it directly.++ *++ * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along++ * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)++ * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`++ *++ * Link hand-off: when this page has a session and a link points at another OpenVibe site, the++ * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).++ * It is a top-level navigation, so it works in every browser with no third-party cookies, and++ * the target answers straight from its own session when it already has one. Sites that share a++ * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with++ * data-ov-nohandoff.++ *++ * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network++ * for an assertion (a native chip the first time per site, silent auto re-auth after that) and++ * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.++ * Never called for browsers the network has told "logged-out", so guests see nothing.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeSSO) return;++++ const NETWORK = 'https://openvibe.network';++ // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.++ const SITES = [++ { test: (h) => h === 'openvibe.live' || h.endsWith('.openvibe.live'), key: 'live', login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={url}', fedcm: 'https://openvibe.live/api/auth/fedcm' },++ { test: (h) => h === 'openvibe.tools' || h.endsWith('.openvibe.tools'), key: 'tools', login: 'https://openvibe.tools/auth/login?silent=1&next={url}', fedcm: 'https://openvibe.tools/auth/fedcm' },++ { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },++ { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },++ ];++ const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;++ const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);++++ /** The URL that signs the browser in on the link's site and lands on the link, or null. */++ function loginUrlFor(href) {++ let u; try { u = new URL(href, location.href); } catch { return null; }++ if (u.protocol !== 'https:') return null;++ const target = siteFor(u.hostname);++ if (!target) return null;++ const mine = here();++ if (mine && mine.key === target.key) return null; // same session cookie already++ return target.login.replace('{url}', encodeURIComponent(u.toString()));++ }++++ let _handoffOn = false, _signedIn = false;++ function rewrite(a) {++ if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;++ const url = loginUrlFor(a.href);++ if (!url) return;++ a.dataset.ovHandoff = a.href;++ a.href = url;++ }++ function onIntent(e) {++ if (!_signedIn) return;++ const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;++ if (a) rewrite(a);++ }++ /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */++ function handoffLinks(opts) {++ _signedIn = !!(opts && opts.signedIn);++ if (_handoffOn) return;++ _handoffOn = true;++ // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.++ for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });++ document.addEventListener('click', onIntent, true);++ document.addEventListener('contextmenu', (e) => {++ // A copied link should stay a plain link.++ const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;++ if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }++ }, true);++ }++++ /** FedCM availability: the API exists and this is a top-level, secure document. */++ function fedcmAvailable() {++ try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }++ }++++ /**++ * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.++ * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth++ * (previously approved on this site), 'optional' = show the chip the first time.++ */++ async function fedcm(opts) {++ const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});++ if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };++ const site = here();++ const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';++ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');++ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);++ let cred;++ try {++ cred = await navigator.credentials.get({++ identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },++ mediation: o.mediation, signal: ctrl.signal,++ });++ } catch (err) {++ clearTimeout(timer);++ return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };++ }++ clearTimeout(timer);++ if (!cred || !cred.token) return { ok: false, reason: 'no-token' };++ try {++ const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };++ try { sessionStorage.setItem('ov_sso_hint', 'account'); localStorage.setItem('ov_sso_hint', 'account'); } catch { /* */ }++ return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };++ } catch (err) { return { ok: false, reason: 'network' }; }++ }++++ /** After an explicit sign-out: the browser must not auto re-auth us back in. */++ function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }++++ root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// FedCM — openvibe.network as a browser-native identity provider.++//++// The Federated Credential Management API lets a site ask the *browser* "is this person signed++// in to openvibe.network, and may I have a token?" — no redirect, no hidden iframe, and it keeps++// working when third-party cookies are blocked, because the browser makes these requests itself++// (Chrome/Edge today; the shared navbar falls back to the iframe check + prompt=none elsewhere).++//++// Contract (developer.chrome.com/docs/identity/fedcm/implement/identity-provider):++// GET /.well-known/web-identity → { provider_urls: [config] } (eTLD+1 root)++// GET /fedcm/config.json → endpoints + branding++// GET /fedcm/accounts → { accounts: [...] } credentialed, Sec-Fetch-Dest: webidentity, no Origin/Referer++// GET /fedcm/client-metadata → { privacy_policy_url, terms_of_service_url }++// POST /fedcm/assertion → { token } credentialed form POST, Origin = the RP, CORS with that exact origin++// POST /fedcm/disconnect → { account_id }++//++// The RP's clientId is its origin. The assertion is a short-lived RS256 JWT (aud = that origin,++// nonce, jti) that the RP's server then exchanges at /oauth/token with the jwt-bearer grant for++// the usual token pair — so every site keeps its existing session logic and the assertion++// itself never becomes a session.++// ═══════════════════════════════════════════════════════════════++const express = require('express');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { OWNED_ZONES } = require('./sso-owned');++++const ASSERTION_TTL_S = 300;++const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED_ZONES.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');++const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;++++function rpOrigin(raw, env = process.env) {++ const o = String(raw || '').trim().replace(/\/$/, '');++ if (ORIGIN_RE.test(o)) return o;++ if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;++ return null;++}++++function isWebIdentity(req) { return String(req.headers['sec-fetch-dest'] || '') === 'webidentity'; }++++function accountOf(user, base) {++ const avatar = user.avatar_url ? (user.avatar_url.startsWith('http') ? user.avatar_url : `${base}${user.avatar_url}`) : `${base}/assets/logo-192.png`;++ return {++ id: String(user.id),++ name: user.display_name || user.username,++ given_name: user.display_name || user.username,++ username: user.username,++ email: user.email || `${user.username}@openvibe.network`,++ picture: avatar,++ };++}++++/** Sign the FedCM assertion for one RP. */++function signAssertion(user, origin, nonce, ctx) {++ return jwt.sign(++ { sub: user.id, id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, nonce: nonce || null, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') },++ ctx.privateKey,++ { algorithm: ctx.privateKey.includes('BEGIN') ? 'RS256' : 'HS256', issuer: ctx.config.jwt.issuer, audience: origin, expiresIn: ASSERTION_TTL_S }++ );++}++++// jti seen within the assertion lifetime → replay refused (one assertion, one session).++const _usedJti = new Map();++function jtiFresh(jti) {++ const now = Date.now();++ if (_usedJti.size > 5000) for (const [k, t] of _usedJti) if (t < now) _usedJti.delete(k);++ if (!jti || _usedJti.has(jti)) return false;++ _usedJti.set(jti, now + ASSERTION_TTL_S * 1000);++ return true;++}++++/**++ * Verify an assertion presented by an RP server through the jwt-bearer grant. `allowedOrigins`++ * is a predicate for the OAuth client doing the exchange (tools may exchange assertions minted++ * for any *.openvibe.tools origin; live only for openvibe.live).++ */++function verifyAssertion(token, ctx, allowedOrigins) {++ const decoded = jwt.verify(token, ctx.publicKey, { algorithms: [ctx.publicKey.includes('BEGIN') ? 'RS256' : 'HS256'], issuer: ctx.config.jwt.issuer });++ if (decoded.typ !== 'fedcm') throw new Error('not a FedCM assertion');++ const aud = Array.isArray(decoded.aud) ? decoded.aud[0] : decoded.aud;++ if (!aud || !allowedOrigins(aud)) throw new Error('assertion audience does not belong to this client');++ if (!jtiFresh(decoded.jti)) throw new Error('assertion already used');++ return decoded;++}++++function createFedcmRoutes(getCtx) {++ const router = express.Router();++ // Browser-initiated fetches from other origins: helmet's same-origin CORP must not apply.++ router.use((req, res, next) => { res.set('Cross-Origin-Resource-Policy', 'cross-origin'); next(); });++ const base = () => (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');++ const sessionUser = (req) => {++ const { verifySession, requestToken } = require('./session');++ const out = verifySession(requestToken(req), getCtx(req));++ return out.error || !out.user || out.user.is_anon || out.user.is_banned ? null : out.user;++ };++++ router.get('/config.json', (req, res) => {++ const b = base();++ res.set('Cache-Control', 'public, max-age=3600');++ res.json({++ accounts_endpoint: `${b}/fedcm/accounts`,++ client_metadata_endpoint: `${b}/fedcm/client-metadata`,++ id_assertion_endpoint: `${b}/fedcm/assertion`,++ disconnect_endpoint: `${b}/fedcm/disconnect`,++ login_url: `${b}/login`,++ supports_use_other_account: true,++ branding: { background_color: '#3b82f6', color: '#ffffff', name: 'OpenVibe', icons: [{ url: `${b}/assets/logo-192.png`, size: 192 }, { url: `${b}/assets/logo-512.png`, size: 512 }] },++ });++ });++++ router.get('/accounts', (req, res) => {++ res.set('Cache-Control', 'no-store');++ if (!isWebIdentity(req)) return res.status(400).json({ error: 'not a FedCM request' });++ const user = sessionUser(req);++ if (!user) { res.set('Set-Login', 'logged-out'); return res.status(401).json({ accounts: [] }); }++ res.set('Set-Login', 'logged-in');++ res.json({ accounts: [accountOf(user, base())] });++ });++++ router.get('/client-metadata', (req, res) => {++ res.set('Cache-Control', 'public, max-age=3600');++ res.json({ privacy_policy_url: 'https://openvibe.live/privacy', terms_of_service_url: 'https://openvibe.live/tos' });++ });++++ router.post('/assertion', express.urlencoded({ extended: false }), (req, res) => {++ res.set('Cache-Control', 'no-store');++ const origin = rpOrigin(req.headers.origin);++ if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: { code: 'invalid_request' } });++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Access-Control-Allow-Credentials', 'true');++ res.set('Vary', 'Origin');++ const clientId = String(req.body?.client_id || '').replace(/\/$/, '');++ if (clientId !== origin) return res.status(403).json({ error: { code: 'unauthorized_client' } });++ const user = sessionUser(req);++ if (!user) return res.status(401).json({ error: { code: 'access_denied' } });++ if (String(req.body?.account_id || '') !== String(user.id)) return res.status(403).json({ error: { code: 'access_denied' } });++ let nonce = req.body?.nonce || null;++ if (!nonce && req.body?.params) { try { nonce = JSON.parse(req.body.params).nonce || null; } catch { /* */ } }++ res.json({ token: signAssertion(user, origin, nonce, getCtx(req)) });++ });++++ router.post('/disconnect', express.urlencoded({ extended: false }), (req, res) => {++ const origin = rpOrigin(req.headers.origin);++ if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: 'invalid_request' });++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Access-Control-Allow-Credentials', 'true');++ const user = sessionUser(req);++ // Nothing is stored per RP today (approval lives in the browser); answer with the account id.++ res.json({ account_id: user ? String(user.id) : String(req.body?.account_hint || '') });++ });++++ return router;++}++++function wellKnown(getCtx) {++ return (req, res) => {++ const b = (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');++ res.set('Cache-Control', 'public, max-age=3600');++ res.set('Cross-Origin-Resource-Policy', 'cross-origin');++ res.json({ provider_urls: [`${b}/fedcm/config.json`] });++ };++}++++module.exports = { createFedcmRoutes, wellKnown, verifyAssertion, signAssertion, rpOrigin, accountOf };@@@ -143,6 +143,8 @@ router.post('/token', (req, res) => {return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri);} else if (grant_type === 'refresh_token') {return handleRefreshGrant(db, config, req, res, client, refresh_token);++ } else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {++ return handleFedcmAssertionGrant(db, config, req, res, client, req.body.assertion);} else {return res.status(400).json({ error: 'unsupported_grant_type' });}@@@ -207,6 +209,31 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {});}++/**++ * RFC 7523 jwt-bearer grant carrying a FedCM assertion (server/auth/fedcm.js): the RP's server++ * hands back the assertion the browser obtained for it and receives the same token pair the++ * code grant issues, so its session logic does not change. The assertion must be ours, unused,++ * unexpired, and minted for an origin this client owns.++ */++function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {++ if (!assertion) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing assertion' });++ let decoded;++ try {++ const { verifyAssertion } = require('./fedcm');++ const { clientOriginMatcher } = require('./sso-owned');++ decoded = verifyAssertion(String(assertion), { publicKey: req.app.locals.publicKey, config }, clientOriginMatcher(client));++ } catch (err) {++ return res.status(400).json({ error: 'invalid_grant', error_description: err.message });++ }++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);++ if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });++ const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);++ recordLinkedService(db, user, client);++ const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);++ const { password_hash, token_valid_after, ...safeUser } = user;++ res.json({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: 86400, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });++}++function handleRefreshGrant(db, config, req, res, client, refreshToken) {if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });@@@ -51,8 +51,10 @@ const SSO_COOKIE = { httpOnly: true, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite:function setSessionCookies(res, token) {res.cookie('ov_token', token, COOKIE);res.cookie('ov_sso', token, SSO_COOKIE);++ res.set('Set-Login', 'logged-in'); // FedCM login-status: the browser may offer this account to RPs}function clearSessionCookies(res) {++ res.set('Set-Login', 'logged-out');res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });}@@@ -15,7 +15,7 @@// ═══════════════════════════════════════════════════════════════// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.--const OWNED = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];++const { OWNED_ZONES: OWNED } = require('./sso-owned');const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;nnew file mode 100644@@@ -0,0 +1,25 @@++'use strict';++// The zones OpenVibe owns. Cross-site trust (sso/check framing, FedCM relying parties, sign-in++// handoff targets) is limited to these — never to openvibe.<anything>, which anyone can register.++const OWNED_ZONES = [++ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',++ 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews',++ 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',++ 'openre.stream',++];++++/** Which RP origins an OAuth client may exchange FedCM assertions for. */++function clientOriginMatcher(client) {++ const id = String(client?.client_id || '');++ const zoneOf = { live: 'openvibe.live', tools: 'openvibe.tools', games: 'openvibe.games', media: 'openvibe.media', community: 'openvibe.community', network: 'openvibe.network' }[id];++ let uris = [];++ try { uris = JSON.parse(client?.redirect_uris || '[]'); } catch { /* */ }++ const registered = new Set(uris.map(u => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));++ return (origin) => {++ if (registered.has(origin)) return true;++ if (!zoneOf) return false;++ try { const h = new URL(origin).hostname; return h === zoneOf || h.endsWith('.' + zoneOf); } catch { return false; }++ };++}++++module.exports = { OWNED_ZONES, clientOriginMatcher };@@@ -538,6 +538,14 @@ app.get('/verify-email', (req, res) => {res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));});++// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js++{++ const fedcm = require('./auth/fedcm');++ const ctx = () => ({ db, publicKey, privateKey, config });++ app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));++ app.use('/fedcm', fedcm.createFedcmRoutes(ctx));++}++// Silent cross-site session check (hidden iframe from any OpenVibe site) — see server/auth/sso-check.jsapp.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ db, publicKey, config })));nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++// FedCM identity-provider endpoints and the jwt-bearer exchange rules.++// node test/fedcm.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const fedcm = require('../server/auth/fedcm');++const { clientOriginMatcher } = require('../server/auth/sso-owned');++++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const config = { jwt: { issuer: 'https://openvibe.network', expiresIn: '1h' }, networkUrl: 'https://openvibe.network' };++const users = { 7: { id: 7, username: 'goosely', display_name: 'Goosely', email: '[email protected]', avatar_url: '/data/avatars/7.png', is_banned: 0, token_valid_after: null } };++const db = { prepare: () => ({ get: (id) => users[id] || null }) };++const ctx = () => ({ db, publicKey, privateKey, config });++const session = jwt.sign({ sub: 7, id: 7, username: 'goosely' }, privateKey, { algorithm: 'RS256', issuer: config.jwt.issuer, expiresIn: '1h' });++++// ── pure rules ───────────────────────────────────────────────++assert.strictEqual(fedcm.rpOrigin('https://yt.openvibe.tools'), 'https://yt.openvibe.tools');++assert.strictEqual(fedcm.rpOrigin('https://openvibe.xyz'), null);++assert.strictEqual(fedcm.rpOrigin('https://openvibe.tools/x'), null);++const tools = clientOriginMatcher({ client_id: 'tools', redirect_uris: '["https://openvibe.tools/auth/callback"]' });++assert.ok(tools('https://yt.openvibe.tools') && tools('https://openvibe.tools') && !tools('https://openvibe.live'), 'tools may exchange for any *.openvibe.tools origin only');++const live = clientOriginMatcher({ client_id: 'live', redirect_uris: '["https://openvibe.live/api/auth/callback"]' });++assert.ok(live('https://openvibe.live') && !live('https://openvibe.tools'));++const a = fedcm.accountOf(users[7], 'https://openvibe.network');++assert.deepStrictEqual(Object.keys(a).sort(), ['email', 'given_name', 'id', 'name', 'picture', 'username']);++assert.strictEqual(a.picture, 'https://openvibe.network/data/avatars/7.png');++++// ── verifyAssertion: audience + single use ───────────────────++const tok = fedcm.signAssertion(users[7], 'https://openvibe.live', 'n1', ctx());++const decoded = fedcm.verifyAssertion(tok, ctx(), live);++assert.strictEqual(decoded.nonce, 'n1'); assert.strictEqual(decoded.typ, 'fedcm');++assert.throws(() => fedcm.verifyAssertion(tok, ctx(), live), /already used/, 'replay refused');++const tok2 = fedcm.signAssertion(users[7], 'https://openvibe.live', null, ctx());++assert.throws(() => fedcm.verifyAssertion(tok2, ctx(), tools), /audience/, 'a client cannot redeem another site\'s assertion');++assert.throws(() => fedcm.verifyAssertion(session, ctx(), live), /not a FedCM assertion/, 'an ordinary session JWT is not an assertion');++++// ── HTTP contract ────────────────────────────────────────────++const app = express();++app.use(cookieParser());++app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));++app.use('/fedcm', fedcm.createFedcmRoutes(ctx));++const server = http.createServer(app);++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = (path, opts = {}) => fetch(base + path, opts).then(async r => ({ status: r.status, headers: r.headers, body: await r.json().catch(() => null) }));++++ let r = await call('/.well-known/web-identity');++ assert.deepStrictEqual(r.body, { provider_urls: ['https://openvibe.network/fedcm/config.json'] });++ r = await call('/fedcm/config.json');++ assert.strictEqual(r.body.accounts_endpoint, 'https://openvibe.network/fedcm/accounts');++ assert.strictEqual(r.body.id_assertion_endpoint, 'https://openvibe.network/fedcm/assertion');++ assert.strictEqual(r.body.login_url, 'https://openvibe.network/login');++ assert.ok(r.body.branding && r.body.branding.icons.length);++++ r = await call('/fedcm/accounts', { headers: { cookie: `ov_sso=${session}` } });++ assert.strictEqual(r.status, 400, 'without Sec-Fetch-Dest: webidentity the request is refused (CSRF guard)');++ r = await call('/fedcm/accounts', { headers: { 'sec-fetch-dest': 'webidentity' } });++ assert.strictEqual(r.status, 401); assert.strictEqual(r.headers.get('set-login'), 'logged-out');++ r = await call('/fedcm/accounts', { headers: { 'sec-fetch-dest': 'webidentity', cookie: `ov_sso=${session}` } });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.accounts[0].id, '7'); assert.strictEqual(r.headers.get('set-login'), 'logged-in');++++ const form = (o) => new URLSearchParams(o).toString();++ const post = (body, headers) => call('/fedcm/assertion', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers }, body: form(body) });++ r = await post({ account_id: '7', client_id: 'https://openvibe.tools', nonce: 'abc' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools', cookie: `ov_sso=${session}` });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.tools');++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), 'true');++ const claims = jwt.verify(r.body.token, publicKey, { algorithms: ['RS256'], audience: 'https://openvibe.tools' });++ assert.strictEqual(claims.nonce, 'abc'); assert.strictEqual(claims.sub, 7);++ r = await post({ account_id: '7', client_id: 'https://openvibe.live', nonce: 'abc' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools', cookie: `ov_sso=${session}` });++ assert.strictEqual(r.status, 403, 'client_id must equal the requesting origin');++ r = await post({ account_id: '8', client_id: 'https://openvibe.tools' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools', cookie: `ov_sso=${session}` });++ assert.strictEqual(r.status, 403, 'account must be the signed-in one');++ r = await post({ account_id: '7', client_id: 'https://evil.example' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://evil.example', cookie: `ov_sso=${session}` });++ assert.strictEqual(r.status, 400, 'foreign origins get nothing');++ r = await post({ account_id: '7', client_id: 'https://openvibe.tools' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools' });++ assert.strictEqual(r.status, 401, 'no session → access_denied');++++ server.close();++ console.log('fedcm: all checks passed');++})().catch((err) => { console.error(err); server.close(); process.exit(1); });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js",
+ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -38,6 +38,10 @@
@@ -38,6 +38,10 @@
// but this browser has signed in to the network before (ov_sso_hint), try one silent
// but this browser has signed in to the network before (ov_sso_hint), try one silent
// prompt=none round trip per tab so a session on one site becomes a session on all.
// prompt=none round trip per tab so a session on one site becomes a session on all.
silentLogin: null,
silentLogin: null,
+ // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first
+ // time and re-authenticates silently afterwards; 'silent' only re-authenticates.
+ fedcm: 'optional',
+ fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)
};
};
const _runtimeMenu = { before: [], after: [] };
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
let _runtimeLinks = null;
@@ -790,6 +794,23 @@
@@ -790,6 +794,23 @@
* never for bots. The site's login route turns silent=1 into prompt=none and comes straight
* never for bots. The site's login route turns silent=1 into prompt=none and comes straight
* back on error=login_required, so a signed-out visitor sees one quick redirect at most.
* back on error=login_required, so a signed-out visitor sees one quick redirect at most.
*/
*/
+ let _ssoClientLoading = null;
+ function loadSsoClient() {
+ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
+ if (_ssoClientLoading) return _ssoClientLoading;
+ _ssoClientLoading = new Promise((resolve) => {
+ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;
+ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
+ document.head.appendChild(sc);
+ });
+ return _ssoClientLoading;
+ }
+
+ /** Signed in here: cross-site links carry the session along (see sso-client.js). */
+ function enableHandoff() {
+ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });
+ }
+
function silentLoginNow() {
function silentLoginNow() {
const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
location.replace(url);
location.replace(url);
@@ -817,7 +838,18 @@
@@ -817,7 +838,18 @@
sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));
sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));
} catch { return false; }
} catch { return false; }
if (hint === 'account') return silentLoginNow();
if (hint === 'account') return silentLoginNow();
- checkNetworkSession().then((state) => { if (state && state.signedIn) silentLoginNow(); });
+ checkNetworkSession().then((state) => {
+ if (state && state.signedIn) return silentLoginNow();
+ // No answer or "not signed in" — either a guest, or a browser that keeps the network's
+ // cookie away from iframes. FedCM asks the browser itself; the network's login status
+ // makes it a no-op for guests, a native chip (then silent re-auth) for signed-in users.
+ if (_config.fedcm === false) return;
+ loadSsoClient().then(async (sso) => {
+ if (!sso || !sso.fedcmAvailable()) return;
+ const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' });
+ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); }
+ });
+ });
return false;
return false;
}
}
@@ -868,6 +900,7 @@
@@ -868,6 +900,7 @@
if (session.token) _config.token = session.token;
if (session.token) _config.token = session.token;
render();
render();
recordHistory();
recordHistory();
+ enableHandoff();
} else {
} else {
maybeSilentLogin();
maybeSilentLogin();
}
}
@@ -1060,6 +1093,8 @@
@@ -1060,6 +1093,8 @@
clearAuthState();
clearAuthState();
_config.user = null;
_config.user = null;
_config.token = null;
_config.token = null;
+ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (onToolsDomain()) {
if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
// and the httpOnly refresh cookie, then sends us back here.
@@ -1101,7 +1136,7 @@
@@ -1101,7 +1136,7 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
if (!_config.user) refreshAuthState();
- else recordHistory();
+ else { recordHistory(); enableHandoff(); }
return el;
return el;
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+/**
+ * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in
+ * across separate domains. Loaded lazily by navbar.js; Live loads it directly.
+ *
+ * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along
+ * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)
+ * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`
+ *
+ * Link hand-off: when this page has a session and a link points at another OpenVibe site, the
+ * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).
+ * It is a top-level navigation, so it works in every browser with no third-party cookies, and
+ * the target answers straight from its own session when it already has one. Sites that share a
+ * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with
+ * data-ov-nohandoff.
+ *
+ * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network
+ * for an assertion (a native chip the first time per site, silent auto re-auth after that) and
+ * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.
+ * Never called for browsers the network has told "logged-out", so guests see nothing.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeSSO) return;
+
+ const NETWORK = 'https://openvibe.network';
+ // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.
+ const SITES = [
+ { test: (h) => h === 'openvibe.live' || h.endsWith('.openvibe.live'), key: 'live', login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={url}', fedcm: 'https://openvibe.live/api/auth/fedcm' },
+ { test: (h) => h === 'openvibe.tools' || h.endsWith('.openvibe.tools'), key: 'tools', login: 'https://openvibe.tools/auth/login?silent=1&next={url}', fedcm: 'https://openvibe.tools/auth/fedcm' },
+ { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
+ { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
+ ];
+ const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;
+ const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);
+
+ /** The URL that signs the browser in on the link's site and lands on the link, or null. */
+ function loginUrlFor(href) {
+ let u; try { u = new URL(href, location.href); } catch { return null; }
+ if (u.protocol !== 'https:') return null;
+ const target = siteFor(u.hostname);
+ if (!target) return null;
+ const mine = here();
+ if (mine && mine.key === target.key) return null; // same session cookie already
+ return target.login.replace('{url}', encodeURIComponent(u.toString()));
+ }
+
+ let _handoffOn = false, _signedIn = false;
+ function rewrite(a) {
+ if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;
+ const url = loginUrlFor(a.href);
+ if (!url) return;
+ a.dataset.ovHandoff = a.href;
+ a.href = url;
+ }
+ function onIntent(e) {
+ if (!_signedIn) return;
+ const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
+ if (a) rewrite(a);
+ }
+ /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */
+ function handoffLinks(opts) {
+ _signedIn = !!(opts && opts.signedIn);
+ if (_handoffOn) return;
+ _handoffOn = true;
+ // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.
+ for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });
+ document.addEventListener('click', onIntent, true);
+ document.addEventListener('contextmenu', (e) => {
+ // A copied link should stay a plain link.
+ const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;
+ if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }
+ }, true);
+ }
+
+ /** FedCM availability: the API exists and this is a top-level, secure document. */
+ function fedcmAvailable() {
+ try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }
+ }
+
+ /**
+ * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.
+ * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth
+ * (previously approved on this site), 'optional' = show the chip the first time.
+ */
+ async function fedcm(opts) {
+ const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});
+ if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };
+ const site = here();
+ const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';
+ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
+ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);
+ let cred;
+ try {
+ cred = await navigator.credentials.get({
+ identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },
+ mediation: o.mediation, signal: ctrl.signal,
+ });
+ } catch (err) {
+ clearTimeout(timer);
+ return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };
+ }
+ clearTimeout(timer);
+ if (!cred || !cred.token) return { ok: false, reason: 'no-token' };
+ try {
+ const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };
+ try { sessionStorage.setItem('ov_sso_hint', 'account'); localStorage.setItem('ov_sso_hint', 'account'); } catch { /* */ }
+ return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };
+ } catch (err) { return { ok: false, reason: 'network' }; }
+ }
+
+ /** After an explicit sign-out: the browser must not auto re-auth us back in. */
+ function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }
+
+ root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// FedCM — openvibe.network as a browser-native identity provider.
+//
+// The Federated Credential Management API lets a site ask the *browser* "is this person signed
+// in to openvibe.network, and may I have a token?" — no redirect, no hidden iframe, and it keeps
+// working when third-party cookies are blocked, because the browser makes these requests itself
+// (Chrome/Edge today; the shared navbar falls back to the iframe check + prompt=none elsewhere).
+//
+// Contract (developer.chrome.com/docs/identity/fedcm/implement/identity-provider):
+// GET /.well-known/web-identity → { provider_urls: [config] } (eTLD+1 root)
+// GET /fedcm/config.json → endpoints + branding
+// GET /fedcm/accounts → { accounts: [...] } credentialed, Sec-Fetch-Dest: webidentity, no Origin/Referer
+// GET /fedcm/client-metadata → { privacy_policy_url, terms_of_service_url }
+// POST /fedcm/assertion → { token } credentialed form POST, Origin = the RP, CORS with that exact origin
+// POST /fedcm/disconnect → { account_id }
+//
+// The RP's clientId is its origin. The assertion is a short-lived RS256 JWT (aud = that origin,
+// nonce, jti) that the RP's server then exchanges at /oauth/token with the jwt-bearer grant for
+// the usual token pair — so every site keeps its existing session logic and the assertion
+// itself never becomes a session.
+// ═══════════════════════════════════════════════════════════════
+const express = require('express');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { OWNED_ZONES } = require('./sso-owned');
+
+const ASSERTION_TTL_S = 300;
+const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED_ZONES.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
+const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
+
+function rpOrigin(raw, env = process.env) {
+ const o = String(raw || '').trim().replace(/\/$/, '');
+ if (ORIGIN_RE.test(o)) return o;
+ if (env.NODE_ENV !== 'production' && LOCAL_RE.test(o)) return o;
+ return null;
+}
+
+function isWebIdentity(req) { return String(req.headers['sec-fetch-dest'] || '') === 'webidentity'; }
+
+function accountOf(user, base) {
+ const avatar = user.avatar_url ? (user.avatar_url.startsWith('http') ? user.avatar_url : `${base}${user.avatar_url}`) : `${base}/assets/logo-192.png`;
+ return {
+ id: String(user.id),
+ name: user.display_name || user.username,
+ given_name: user.display_name || user.username,
+ username: user.username,
+ email: user.email || `${user.username}@openvibe.network`,
+ picture: avatar,
+ };
+}
+
+/** Sign the FedCM assertion for one RP. */
+function signAssertion(user, origin, nonce, ctx) {
+ return jwt.sign(
+ { sub: user.id, id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, nonce: nonce || null, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') },
+ ctx.privateKey,
+ { algorithm: ctx.privateKey.includes('BEGIN') ? 'RS256' : 'HS256', issuer: ctx.config.jwt.issuer, audience: origin, expiresIn: ASSERTION_TTL_S }
+ );
+}
+
+// jti seen within the assertion lifetime → replay refused (one assertion, one session).
+const _usedJti = new Map();
+function jtiFresh(jti) {
+ const now = Date.now();
+ if (_usedJti.size > 5000) for (const [k, t] of _usedJti) if (t < now) _usedJti.delete(k);
+ if (!jti || _usedJti.has(jti)) return false;
+ _usedJti.set(jti, now + ASSERTION_TTL_S * 1000);
+ return true;
+}
+
+/**
+ * Verify an assertion presented by an RP server through the jwt-bearer grant. `allowedOrigins`
+ * is a predicate for the OAuth client doing the exchange (tools may exchange assertions minted
+ * for any *.openvibe.tools origin; live only for openvibe.live).
+ */
+function verifyAssertion(token, ctx, allowedOrigins) {
+ const decoded = jwt.verify(token, ctx.publicKey, { algorithms: [ctx.publicKey.includes('BEGIN') ? 'RS256' : 'HS256'], issuer: ctx.config.jwt.issuer });
+ if (decoded.typ !== 'fedcm') throw new Error('not a FedCM assertion');
+ const aud = Array.isArray(decoded.aud) ? decoded.aud[0] : decoded.aud;
+ if (!aud || !allowedOrigins(aud)) throw new Error('assertion audience does not belong to this client');
+ if (!jtiFresh(decoded.jti)) throw new Error('assertion already used');
+ return decoded;
+}
+
+function createFedcmRoutes(getCtx) {
+ const router = express.Router();
+ // Browser-initiated fetches from other origins: helmet's same-origin CORP must not apply.
+ router.use((req, res, next) => { res.set('Cross-Origin-Resource-Policy', 'cross-origin'); next(); });
+ const base = () => (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');
+ const sessionUser = (req) => {
+ const { verifySession, requestToken } = require('./session');
+ const out = verifySession(requestToken(req), getCtx(req));
+ return out.error || !out.user || out.user.is_anon || out.user.is_banned ? null : out.user;
+ };
+
+ router.get('/config.json', (req, res) => {
+ const b = base();
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.json({
+ accounts_endpoint: `${b}/fedcm/accounts`,
+ client_metadata_endpoint: `${b}/fedcm/client-metadata`,
+ id_assertion_endpoint: `${b}/fedcm/assertion`,
+ disconnect_endpoint: `${b}/fedcm/disconnect`,
+ login_url: `${b}/login`,
+ supports_use_other_account: true,
+ branding: { background_color: '#3b82f6', color: '#ffffff', name: 'OpenVibe', icons: [{ url: `${b}/assets/logo-192.png`, size: 192 }, { url: `${b}/assets/logo-512.png`, size: 512 }] },
+ });
+ });
+
+ router.get('/accounts', (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ if (!isWebIdentity(req)) return res.status(400).json({ error: 'not a FedCM request' });
+ const user = sessionUser(req);
+ if (!user) { res.set('Set-Login', 'logged-out'); return res.status(401).json({ accounts: [] }); }
+ res.set('Set-Login', 'logged-in');
+ res.json({ accounts: [accountOf(user, base())] });
+ });
+
+ router.get('/client-metadata', (req, res) => {
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.json({ privacy_policy_url: 'https://openvibe.live/privacy', terms_of_service_url: 'https://openvibe.live/tos' });
+ });
+
+ router.post('/assertion', express.urlencoded({ extended: false }), (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const origin = rpOrigin(req.headers.origin);
+ if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: { code: 'invalid_request' } });
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Access-Control-Allow-Credentials', 'true');
+ res.set('Vary', 'Origin');
+ const clientId = String(req.body?.client_id || '').replace(/\/$/, '');
+ if (clientId !== origin) return res.status(403).json({ error: { code: 'unauthorized_client' } });
+ const user = sessionUser(req);
+ if (!user) return res.status(401).json({ error: { code: 'access_denied' } });
+ if (String(req.body?.account_id || '') !== String(user.id)) return res.status(403).json({ error: { code: 'access_denied' } });
+ let nonce = req.body?.nonce || null;
+ if (!nonce && req.body?.params) { try { nonce = JSON.parse(req.body.params).nonce || null; } catch { /* */ } }
+ res.json({ token: signAssertion(user, origin, nonce, getCtx(req)) });
+ });
+
+ router.post('/disconnect', express.urlencoded({ extended: false }), (req, res) => {
+ const origin = rpOrigin(req.headers.origin);
+ if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: 'invalid_request' });
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Access-Control-Allow-Credentials', 'true');
+ const user = sessionUser(req);
+ // Nothing is stored per RP today (approval lives in the browser); answer with the account id.
+ res.json({ account_id: user ? String(user.id) : String(req.body?.account_hint || '') });
+ });
+
+ return router;
+}
+
+function wellKnown(getCtx) {
+ return (req, res) => {
+ const b = (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.set('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.json({ provider_urls: [`${b}/fedcm/config.json`] });
+ };
+}
+
+module.exports = { createFedcmRoutes, wellKnown, verifyAssertion, signAssertion, rpOrigin, accountOf };
@@ -143,6 +143,8 @@ router.post('/token', (req, res) => {
@@ -143,6 +143,8 @@ router.post('/token', (req, res) => {
return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri);
return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri);
} else if (grant_type === 'refresh_token') {
} else if (grant_type === 'refresh_token') {
return handleRefreshGrant(db, config, req, res, client, refresh_token);
return handleRefreshGrant(db, config, req, res, client, refresh_token);
+ } else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
+ return handleFedcmAssertionGrant(db, config, req, res, client, req.body.assertion);
} else {
} else {
return res.status(400).json({ error: 'unsupported_grant_type' });
return res.status(400).json({ error: 'unsupported_grant_type' });
}
}
@@ -207,6 +209,31 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
@@ -207,6 +209,31 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
});
});
}
}
+/**
+ * RFC 7523 jwt-bearer grant carrying a FedCM assertion (server/auth/fedcm.js): the RP's server
+ * hands back the assertion the browser obtained for it and receives the same token pair the
+ * code grant issues, so its session logic does not change. The assertion must be ours, unused,
+ * unexpired, and minted for an origin this client owns.
+ */
+function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {
+ if (!assertion) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing assertion' });
+ let decoded;
+ try {
+ const { verifyAssertion } = require('./fedcm');
+ const { clientOriginMatcher } = require('./sso-owned');
+ decoded = verifyAssertion(String(assertion), { publicKey: req.app.locals.publicKey, config }, clientOriginMatcher(client));
+ } catch (err) {
+ return res.status(400).json({ error: 'invalid_grant', error_description: err.message });
+ }
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
+ if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
+ const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);
+ recordLinkedService(db, user, client);
+ const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
+ const { password_hash, token_valid_after, ...safeUser } = user;
+ res.json({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: 86400, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });
+}
+
function handleRefreshGrant(db, config, req, res, client, refreshToken) {
function handleRefreshGrant(db, config, req, res, client, refreshToken) {
if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
@@ -51,8 +51,10 @@ const SSO_COOKIE = { httpOnly: true, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite:
@@ -51,8 +51,10 @@ const SSO_COOKIE = { httpOnly: true, maxAge: 90 * 24 * 60 * 60 * 1000, sameSite:
function setSessionCookies(res, token) {
function setSessionCookies(res, token) {
res.cookie('ov_token', token, COOKIE);
res.cookie('ov_token', token, COOKIE);
res.cookie('ov_sso', token, SSO_COOKIE);
res.cookie('ov_sso', token, SSO_COOKIE);
+ res.set('Set-Login', 'logged-in'); // FedCM login-status: the browser may offer this account to RPs
}
}
function clearSessionCookies(res) {
function clearSessionCookies(res) {
+ res.set('Set-Login', 'logged-out');
res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });
res.clearCookie('ov_token', { path: '/', sameSite: 'Lax', secure: true });
res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });
res.clearCookie('ov_sso', { path: '/', sameSite: 'None', secure: true, httpOnly: true });
}
}
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.
// Only the zones OpenVibe owns — not openvibe.<anything>, which anyone could register.
-const OWNED = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
+const { OWNED_ZONES: OWNED } = require('./sso-owned');
const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
const ORIGIN_RE = new RegExp(`^https://(?:[a-z0-9-]+\\.)*(?:${OWNED.map(d => d.replace(/\./g, '\\.')).join('|')})$`, 'i');
const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
const LOCAL_RE = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+'use strict';
+// The zones OpenVibe owns. Cross-site trust (sso/check framing, FedCM relying parties, sign-in
+// handoff targets) is limited to these — never to openvibe.<anything>, which anyone can register.
+const OWNED_ZONES = [
+ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',
+ 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews',
+ 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',
+ 'openre.stream',
+];
+
+/** Which RP origins an OAuth client may exchange FedCM assertions for. */
+function clientOriginMatcher(client) {
+ const id = String(client?.client_id || '');
+ const zoneOf = { live: 'openvibe.live', tools: 'openvibe.tools', games: 'openvibe.games', media: 'openvibe.media', community: 'openvibe.community', network: 'openvibe.network' }[id];
+ let uris = [];
+ try { uris = JSON.parse(client?.redirect_uris || '[]'); } catch { /* */ }
+ const registered = new Set(uris.map(u => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));
+ return (origin) => {
+ if (registered.has(origin)) return true;
+ if (!zoneOf) return false;
+ try { const h = new URL(origin).hostname; return h === zoneOf || h.endsWith('.' + zoneOf); } catch { return false; }
+ };
+}
+
+module.exports = { OWNED_ZONES, clientOriginMatcher };
@@ -538,6 +538,14 @@ app.get('/verify-email', (req, res) => {
@@ -538,6 +538,14 @@ app.get('/verify-email', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));
res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));
});
});
+// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js
+{
+ const fedcm = require('./auth/fedcm');
+ const ctx = () => ({ db, publicKey, privateKey, config });
+ app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
+ app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
+}
+
// Silent cross-site session check (hidden iframe from any OpenVibe site) — see server/auth/sso-check.js
// Silent cross-site session check (hidden iframe from any OpenVibe site) — see server/auth/sso-check.js
app.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ db, publicKey, config })));
app.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ db, publicKey, config })));
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+// FedCM identity-provider endpoints and the jwt-bearer exchange rules.
+// node test/fedcm.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const fedcm = require('../server/auth/fedcm');
+const { clientOriginMatcher } = require('../server/auth/sso-owned');
+
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const config = { jwt: { issuer: 'https://openvibe.network', expiresIn: '1h' }, networkUrl: 'https://openvibe.network' };
+const users = { 7: { id: 7, username: 'goosely', display_name: 'Goosely', email: '[email protected]', avatar_url: '/data/avatars/7.png', is_banned: 0, token_valid_after: null } };
+const db = { prepare: () => ({ get: (id) => users[id] || null }) };
+const ctx = () => ({ db, publicKey, privateKey, config });
+const session = jwt.sign({ sub: 7, id: 7, username: 'goosely' }, privateKey, { algorithm: 'RS256', issuer: config.jwt.issuer, expiresIn: '1h' });
+
+// ── pure rules ───────────────────────────────────────────────
+assert.strictEqual(fedcm.rpOrigin('https://yt.openvibe.tools'), 'https://yt.openvibe.tools');
+assert.strictEqual(fedcm.rpOrigin('https://openvibe.xyz'), null);
+assert.strictEqual(fedcm.rpOrigin('https://openvibe.tools/x'), null);
+const tools = clientOriginMatcher({ client_id: 'tools', redirect_uris: '["https://openvibe.tools/auth/callback"]' });
+assert.ok(tools('https://yt.openvibe.tools') && tools('https://openvibe.tools') && !tools('https://openvibe.live'), 'tools may exchange for any *.openvibe.tools origin only');
+const live = clientOriginMatcher({ client_id: 'live', redirect_uris: '["https://openvibe.live/api/auth/callback"]' });
+assert.ok(live('https://openvibe.live') && !live('https://openvibe.tools'));
+const a = fedcm.accountOf(users[7], 'https://openvibe.network');
+assert.deepStrictEqual(Object.keys(a).sort(), ['email', 'given_name', 'id', 'name', 'picture', 'username']);
+assert.strictEqual(a.picture, 'https://openvibe.network/data/avatars/7.png');
+
+// ── verifyAssertion: audience + single use ───────────────────
+const tok = fedcm.signAssertion(users[7], 'https://openvibe.live', 'n1', ctx());
+const decoded = fedcm.verifyAssertion(tok, ctx(), live);
+assert.strictEqual(decoded.nonce, 'n1'); assert.strictEqual(decoded.typ, 'fedcm');
+assert.throws(() => fedcm.verifyAssertion(tok, ctx(), live), /already used/, 'replay refused');
+const tok2 = fedcm.signAssertion(users[7], 'https://openvibe.live', null, ctx());
+assert.throws(() => fedcm.verifyAssertion(tok2, ctx(), tools), /audience/, 'a client cannot redeem another site\'s assertion');
+assert.throws(() => fedcm.verifyAssertion(session, ctx(), live), /not a FedCM assertion/, 'an ordinary session JWT is not an assertion');
+
+// ── HTTP contract ────────────────────────────────────────────
+const app = express();
+app.use(cookieParser());
+app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
+app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
+const server = http.createServer(app);
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = (path, opts = {}) => fetch(base + path, opts).then(async r => ({ status: r.status, headers: r.headers, body: await r.json().catch(() => null) }));
+
+ let r = await call('/.well-known/web-identity');
+ assert.deepStrictEqual(r.body, { provider_urls: ['https://openvibe.network/fedcm/config.json'] });
+ r = await call('/fedcm/config.json');
+ assert.strictEqual(r.body.accounts_endpoint, 'https://openvibe.network/fedcm/accounts');
+ assert.strictEqual(r.body.id_assertion_endpoint, 'https://openvibe.network/fedcm/assertion');
+ assert.strictEqual(r.body.login_url, 'https://openvibe.network/login');
+ assert.ok(r.body.branding && r.body.branding.icons.length);
+
+ r = await call('/fedcm/accounts', { headers: { cookie: `ov_sso=${session}` } });
+ assert.strictEqual(r.status, 400, 'without Sec-Fetch-Dest: webidentity the request is refused (CSRF guard)');
+ r = await call('/fedcm/accounts', { headers: { 'sec-fetch-dest': 'webidentity' } });
+ assert.strictEqual(r.status, 401); assert.strictEqual(r.headers.get('set-login'), 'logged-out');
+ r = await call('/fedcm/accounts', { headers: { 'sec-fetch-dest': 'webidentity', cookie: `ov_sso=${session}` } });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.accounts[0].id, '7'); assert.strictEqual(r.headers.get('set-login'), 'logged-in');
+
+ const form = (o) => new URLSearchParams(o).toString();
+ const post = (body, headers) => call('/fedcm/assertion', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers }, body: form(body) });
+ r = await post({ account_id: '7', client_id: 'https://openvibe.tools', nonce: 'abc' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools', cookie: `ov_sso=${session}` });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.tools');
+ assert.strictEqual(r.headers.get('access-control-allow-credentials'), 'true');
+ const claims = jwt.verify(r.body.token, publicKey, { algorithms: ['RS256'], audience: 'https://openvibe.tools' });
+ assert.strictEqual(claims.nonce, 'abc'); assert.strictEqual(claims.sub, 7);
+ r = await post({ account_id: '7', client_id: 'https://openvibe.live', nonce: 'abc' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools', cookie: `ov_sso=${session}` });
+ assert.strictEqual(r.status, 403, 'client_id must equal the requesting origin');
+ r = await post({ account_id: '8', client_id: 'https://openvibe.tools' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools', cookie: `ov_sso=${session}` });
+ assert.strictEqual(r.status, 403, 'account must be the signed-in one');
+ r = await post({ account_id: '7', client_id: 'https://evil.example' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://evil.example', cookie: `ov_sso=${session}` });
+ assert.strictEqual(r.status, 400, 'foreign origins get nothing');
+ r = await post({ account_id: '7', client_id: 'https://openvibe.tools' }, { 'sec-fetch-dest': 'webidentity', origin: 'https://openvibe.tools' });
+ assert.strictEqual(r.status, 401, 'no session → access_denied');
+
+ server.close();
+ console.log('fedcm: all checks passed');
+})().catch((err) => { console.error(err); server.close(); process.exit(1); });