openvibers/openvibe.shared sso-client.js

sso-client.js

main 7.3 KB · 119 lines · javascript

1/**
2 * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in
3 * across separate domains. Loaded lazily by navbar.js; Live loads it directly.
4 *
5 * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along
6 * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)
7 * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`
8 *
9 * Link hand-off: when this page has a session and a link points at another OpenVibe site, the
10 * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).
11 * It is a top-level navigation, so it works in every browser with no third-party cookies, and
12 * the target answers straight from its own session when it already has one. Sites that share a
13 * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with
14 * data-ov-nohandoff.
15 *
16 * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network
17 * for an assertion (a native chip the first time per site, silent auto re-auth after that) and
18 * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.
19 * Never called for browsers the network has told "logged-out", so guests see nothing.
20 */
21(function (root) {
22 'use strict';
23 if (root.OpenVibeSSO) return;
24
25 const NETWORK = 'https://openvibe.network';
26 // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.
27 const SITES = [
28 { 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' },
29 { 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' },
30 { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
31 { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
32 ];
33 const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;
34 const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);
35
36 /** The URL that signs the browser in on the link's site and lands on the link, or null. */
37 function loginUrlFor(href) {
38 let u; try { u = new URL(href, location.href); } catch { return null; }
39 if (u.protocol !== 'https:') return null;
40 const target = siteFor(u.hostname);
41 if (!target) return null;
42 const mine = here();
43 if (mine && mine.key === target.key) return null; // same session cookie already
44 return target.login.replace('{url}', encodeURIComponent(u.toString()));
45 }
46
47 let _handoffOn = false, _signedIn = false;
48 function rewrite(a) {
49 if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;
50 const url = loginUrlFor(a.href);
51 if (!url) return;
52 a.dataset.ovHandoff = a.href;
53 a.href = url;
54 }
55 function onIntent(e) {
56 if (!_signedIn) return;
57 const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
58 if (a) rewrite(a);
59 }
60 /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */
61 function handoffLinks(opts) {
62 _signedIn = !!(opts && opts.signedIn);
63 if (_handoffOn) return;
64 _handoffOn = true;
65 // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.
66 for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });
67 document.addEventListener('click', onIntent, true);
68 document.addEventListener('contextmenu', (e) => {
69 // A copied link should stay a plain link.
70 const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;
71 if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }
72 }, true);
73 }
74
75 /** FedCM availability: the API exists and this is a top-level, secure document. */
76 function fedcmAvailable() {
77 try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }
78 }
79
80 /**
81 * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.
82 * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth
83 * (previously approved on this site), 'optional' = show the chip the first time.
84 */
85 async function fedcm(opts) {
86 const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});
87 if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };
88 const site = here();
89 const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';
90 const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
91 const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);
92 let cred;
93 try {
94 cred = await navigator.credentials.get({
95 identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },
96 mediation: o.mediation, signal: ctrl.signal,
97 });
98 } catch (err) {
99 clearTimeout(timer);
100 return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };
101 }
102 clearTimeout(timer);
103 if (!cred || !cred.token) return { ok: false, reason: 'no-token' };
104 try {
105 const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });
106 const data = await res.json().catch(() => ({}));
107 if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };
108 const who = data.user && /^[A-Za-z0-9_.-]{1,64}$/.test(data.user.username || '') ? ':' + data.user.username : '';
109 try { sessionStorage.setItem('ov_sso_hint', 'account' + who); localStorage.setItem('ov_sso_hint', 'account' + who); } catch { /* */ }
110 return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };
111 } catch (err) { return { ok: false, reason: 'network' }; }
112 }
113
114 /** After an explicit sign-out: the browser must not auto re-auth us back in. */
115 function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }
116
117 root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };
118})(typeof window !== 'undefined' ? window : globalThis);
119