openvibers/openvibe.network Commits

FedCM identity provider, link hand-off between sites, jwt-bearer exchange

OP OpenVibers committed 18 Sept 2026 · 23ceca10d6 · 10 files, +468 −4

- openvibe.network is a FedCM IdP: /.well-known/web-identity, /fedcm/config.json, /fedcm/accounts
  (credentialed, Sec-Fetch-Dest guard, Set-Login), /fedcm/client-metadata, /fedcm/assertion
  (Origin must equal client_id and be an owned zone; CORS with that exact origin; RS256
  assertion aud=origin, nonce, jti, 5 min), /fedcm/disconnect; Set-Login on every session change
- /oauth/token grant urn:ietf:params:oauth:grant-type:jwt-bearer: an RP server redeems the
  assertion for the usual token pair — audience must belong to that client (tools: any
  *.openvibe.tools), single use — so every site keeps its existing session code
- shared sso-client.js: (1) link hand-off — while signed in, a click on a link to another
  OpenVibe site goes through that site's silent sign-in and lands on the link (works in every
  browser, no third-party cookies; sites sharing a cookie domain are one site; data-ov-nohandoff
  opts out; copied links stay plain); (2) FedCM relying-party call + POST /auth/fedcm
- navbar: signed in → hand-off on; no session → hint → hidden check → FedCM (chip once per site,
  silent re-auth after); sign-out → preventSilentAccess + hint 'guest'
- owned zones list shared by sso-check and fedcm; tests: fedcm (contract, audience, replay)

Changes

ddiff --git a/package.json b/package.json
iindex 36657a37..43d7dfcc 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/packages/openvibe-shared/navbar.js b/packages/openvibe-shared/navbar.js
iindex 977d960f..f59da4df 100644
---- a/packages/openvibe-shared/navbar.js
++++ b/packages/openvibe-shared/navbar.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;
},
ddiff --git a/packages/openvibe-shared/sso-client.js b/packages/openvibe-shared/sso-client.js
nnew file mode 100644
iindex 00000000..50fed6f4
---- /dev/null
++++ b/packages/openvibe-shared/sso-client.js
@@@ -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);
ddiff --git a/server/auth/fedcm.js b/server/auth/fedcm.js
nnew file mode 100644
iindex 00000000..8a69f319
---- /dev/null
++++ b/server/auth/fedcm.js
@@@ -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 };
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 4c497500..81f4dd67 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -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' });
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex eecdfe40..66df001a 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -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 });
}
ddiff --git a/server/auth/sso-check.js b/server/auth/sso-check.js
iindex 2fbb21d5..82216f74 100644
---- a/server/auth/sso-check.js
++++ b/server/auth/sso-check.js
@@@ -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+)?$/;
ddiff --git a/server/auth/sso-owned.js b/server/auth/sso-owned.js
nnew file mode 100644
iindex 00000000..936ee629
---- /dev/null
++++ b/server/auth/sso-owned.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 98a26f5e..4d817450 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.js
app.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ db, publicKey, config })));
ddiff --git a/test/fedcm.test.js b/test/fedcm.test.js
nnew file mode 100644
iindex 00000000..dc10b37a
---- /dev/null
++++ b/test/fedcm.test.js
@@@ -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); });