openvibers/openvibe.shared sso-client.js
sso-client.js
| 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 | ; |
| 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 |