openvibers/openvibe.sources Commits
Pick up Network sessions on Sources arrival (#276)
Changes
@@@ -67,6 +67,7 @@ server {location = /api/ready { proxy_pass http://127.0.0.1:4720; }location = /release.json { proxy_pass http://127.0.0.1:4720; }location = /auth/health { proxy_pass http://127.0.0.1:4720; }++ location = /auth/me { proxy_pass http://127.0.0.1:4720; }# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).location ~* ^/metrics(/|$) { return 404; }nnew file mode 100644@@@ -0,0 +1,43 @@++/* The Sources home and merchant guest page pick up an existing Network session. */++(async () => {++ 'use strict';++ if (new URLSearchParams(location.search).get('sso') === 'none') return;++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return;++ const hint = (document.cookie.match(/(?:^|;\s*)ov_sso_hint=([^;]*)/) || [])[1];++ if (hint === 'guest') return;++ try {++ const r = await fetch('/auth/me', { credentials: 'same-origin', cache: 'no-store' });++ if (r.ok && (await r.json()).user) return;++ } catch { /* A failed check cannot grant a session. */ }++++ try {++ if (Date.now() - Number(sessionStorage.getItem('ov_sources_silent_at') || 0) < 10 * 60 * 1000) return;++ } catch { return; }++ const signIn = () => {++ try { sessionStorage.setItem('ov_sources_silent_at', String(Date.now())); } catch { return; }++ const next = location.pathname === '/' ? '/' : '/merchant';++ location.replace(`/merchant/login?silent=1&next=${encodeURIComponent(next)}`);++ };++ let fromNetwork = false;++ try { fromNetwork = new URL(document.referrer).origin === 'https://openvibe.network'; } catch { /* no referrer */ }++ if (hint === 'account' || fromNetwork) return signIn();++++ const frame = document.createElement('iframe');++ frame.setAttribute('aria-hidden', 'true');++ frame.setAttribute('tabindex', '-1');++ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';++ const finish = (signedIn) => {++ clearTimeout(timer);++ window.removeEventListener('message', onMessage);++ frame.remove();++ if (signedIn) signIn();++ };++ const onMessage = (event) => {++ if (event.origin !== 'https://openvibe.network' || event.source !== frame.contentWindow || event.data?.type !== 'ov-sso') return;++ finish(event.data.signedIn === true);++ };++ window.addEventListener('message', onMessage);++ const timer = setTimeout(() => finish(false), 4000);++ frame.src = `https://openvibe.network/sso/check?origin=${encodeURIComponent(location.origin)}`;++ document.body.appendChild(frame);++})();@@@ -118,6 +118,12 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo// GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it. Sources has// no site-wide sign-in; the merchant flow is the one OAuth client, so it is the one this checks.if (merchantAuth) {++ app.get('/auth/me', async (req, res, next) => {++ try {++ const person = await merchantAuth.resolvePage(req);++ return res.set('Cache-Control', 'no-store').json({ user: person ? { id: person.subject, username: person.name } : null });++ } catch (err) { return next(err); }++ });app.get('/auth/health', async (_req, res) => {const client = await merchantAuth.checkClient();res.setHeader('Cache-Control', 'no-store');@@@ -133,7 +139,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outboconst networkOrigin = (() => { try { return new URL(config.networkUrl).origin; } catch { return 'https://openvibe.network'; } })();app.use('/merchant', (req, res, next) => {res.setHeader('X-Robots-Tag', 'noindex, nofollow');-- res.setHeader('Content-Security-Policy', `default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self' ${networkOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);++ res.setHeader('Content-Security-Policy', `default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self' ${networkOrigin}; frame-src ${networkOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);next();}, express.urlencoded({ extended: false, limit: '32kb' }), merchantAuth.page, merchantPages({db, merchant, registry, ingest, merchantAuth, limits, log,@@@ -147,11 +153,9 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbores.setHeader('X-Robots-Tag', 'noindex, nofollow');res.setHeader('Vary', 'Accept');if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {-- // The page runs no script of its own. Cloudflare Web Analytics: Cloudflare injects its beacon at the-- // edge and the privacy text says it may measure performance; script-src loads it, connect-src is-- // where it reports.++ // The arrival script checks this host's merchant session and Network's silent SSO frame.// The page is the kit's (views/home.js): the theme loader and kit-web from this host's /shared.-- res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; img-src 'self' data:; connect-src 'self' https://openvibe.network https://cloudflareinsights.com; base-uri 'none'; frame-ancestors 'none'");++ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; img-src 'self' data:; connect-src 'self' https://openvibe.network https://cloudflareinsights.com; frame-src https://openvibe.network; base-uri 'none'; frame-ancestors 'none'");return res.type('html').send(homePage());}return res.type('text/plain').send(TEXT_INDEX);@@@ -52,6 +52,7 @@ function bearer(req) {function safeNext(value) {const n = String(value == null ? '' : value);if (!n || /[\u0000-\u001f\u007f\\]/.test(n)) return '/merchant';++ if (n === '/') return '/';if (!/^\/merchant(?:\/|$|\?|#)/.test(n)) return '/merchant';return n;}@@@ -165,7 +166,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThiconst existing = await resolvePage(req);if (existing) return res.redirect(303, next);// openvibe-sdk/sso: state + PKCE S256. The verifier rides the same /merchant flow cookie.-- const started = sso.authorizeUrl();++ const started = sso.authorizeUrl({ silent: req.query.silent === '1' });res.cookie(STATE_COOKIE, started.state, flowCookie);res.cookie(VERIFIER_COOKIE, started.verifier, flowCookie);res.cookie(NEXT_COOKIE, next, flowCookie);@@@ -180,7 +181,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThires.clearCookie(STATE_COOKIE, { ...flowCookie, maxAge: undefined });res.clearCookie(VERIFIER_COOKIE, { ...flowCookie, maxAge: undefined });res.clearCookie(NEXT_COOKIE, { ...flowCookie, maxAge: undefined });-- if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}`);++ if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}&sso=none`);// The state (constant time) and the PKCE verifier must both come from this browser.if (!code || !state || !expected || !verifier || String(state).length !== String(expected).length|| !crypto.timingSafeEqual(Buffer.from(String(state)), Buffer.from(String(expected)))) {@@@ -196,6 +197,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThiconst u = await auth.verifyUser(data.access_token);if (!u) throw Object.assign(new Error('the Network returned a token without a person subject'), { status: 401 });setSession(res, data.access_token);++ res.cookie('ov_sso_hint', 'account', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: SESSION_MAX_AGE });return res.redirect(303, next);} catch (err) {log.error(`[merchant] sign-in failed: ${err.message}`);@@@ -205,6 +207,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThifunction logout(req, res) {clearSession(res);++ res.cookie('ov_sso_hint', 'guest', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: SESSION_MAX_AGE });return res.redirect(303, '/merchant');}@@@ -11,6 +11,7 @@* GET /merchant/login|callback|logout*/const express = require('express');++const path = require('path');const { MerchantError } = require('./merchant');const views = require('./views/merchant');const { createMerchantView } = require('./api/merchant');@@@ -24,6 +25,8 @@ const NOTICES = {function merchantPages({ db, merchant, registry, ingest, merchantAuth, limits, log = console }) {const router = express.Router();++ router.get('/arrival.js', (_req, res) => res.type('application/javascript').set('Cache-Control', 'public, max-age=300')++ .sendFile(path.join(__dirname, '..', 'public', 'merchant-arrival.js')));const decorate = createMerchantView({ db, merchant, registry, ingest });const readLimit = limits.budget('sources.merchant.read');const manageLimit = limits.budget('sources.merchant.manage');@@@ -41,7 +41,7 @@ ${kit.section({door('Source code', 'https://github.com/OpenVibers/OpenVibe.Sources', 'OpenVibers/OpenVibe.Sources on GitHub (AGPL-3.0)'),])),})}`;-- return layout({ title: 'OpenVibe.Sources', description: 'The registry of outside sources the OpenVibe network reads, and where merchants list their store.', body })++ return layout({ title: 'OpenVibe.Sources', description: 'The registry of outside sources the OpenVibe network reads, and where merchants list their store.', body, arrival: true })// The layout titles its pages "… · OpenVibe.Sources"; the home is the site itself..replace('<title>OpenVibe.Sources · OpenVibe.Sources</title>', '<title>OpenVibe.Sources</title>');}@@@ -27,7 +27,7 @@ const when = (iso) => {const csrfInput = (csrf) => `<input type="hidden" name="csrf" value="${esc(csrf)}">`;--function layout({ title = 'List your store on OpenVibe', body = '', description = '' }) {++function layout({ title = 'List your store on OpenVibe', body = '', description = '', arrival = false }) {return `<!doctype html><html lang="en"><head>@@@ -48,6 +48,7 @@ ${description ? `<meta name="description" content="${esc(description)}">` : ''}${body}</main><script src="/shared/kit-web.js" defer></script>++${arrival ? '<script src="/merchant/arrival.js" defer></script>' : ''}</body></html>`;}@@@ -74,7 +75,7 @@ ${kit.section({body: `<p class="ovk-note">A merchant signs in with an OpenVibe.Network account. The claim belongs to that account.</p><p>${kit.button({ href: '/merchant/login?next=%2Fmerchant', label: 'Sign in with OpenVibe', icon: 'user' })}</p>`,})}`;-- return layout({ body });++ return layout({ body, arrival: true });}function claimRow(claim) {nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { suite } = require('./helpers');++++const t = suite('merchant-arrival');++const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'merchant-arrival.js'), 'utf8');++++async function visit({ hint = '', session = null, referrer = '', pathname = '/', search = '' } = {}) {++ const calls = { fetch: 0, navigations: [], frames: [], message: null };++ const frame = { contentWindow: {}, setAttribute() {}, style: {}, remove() { calls.removed = true; } };++ const document = {++ cookie: hint ? `ov_sso_hint=${hint}` : '', referrer,++ createElement(tag) { assert.strictEqual(tag, 'iframe'); return frame; },++ body: { appendChild(el) { calls.frames.push(el); } },++ };++ const window = {++ addEventListener(type, fn) { assert.strictEqual(type, 'message'); calls.message = fn; },++ removeEventListener() { calls.message = null; },++ };++ const storage = new Map();++ await vm.runInNewContext(script, {++ URL, URLSearchParams, document, window,++ location: { origin: 'https://sources.openvibe.network', pathname, search, replace(url) { calls.navigations.push(url); } },++ navigator: { userAgent: 'Chrome' },++ sessionStorage: { getItem(k) { return storage.get(k) || null; }, setItem(k, v) { storage.set(k, v); } },++ async fetch(url) { assert.strictEqual(url, '/auth/me'); calls.fetch++; return { ok: true, async json() { return { user: session }; } }; },++ setTimeout() { return 1; }, clearTimeout() {},++ });++ return { calls, frame };++}++++t('an explicit guest hint makes no session request or navigation', async () => {++ const { calls } = await visit({ hint: 'guest', referrer: 'https://openvibe.network/' });++ assert.strictEqual(calls.fetch, 0);++ assert.deepStrictEqual(calls.navigations, []);++ assert.strictEqual(calls.frames.length, 0);++});++++t('an existing Sources session needs no new OAuth flow', async () => {++ const { calls } = await visit({ hint: 'account', session: { username: 'alice' } });++ assert.strictEqual(calls.fetch, 1);++ assert.deepStrictEqual(calls.navigations, []);++});++++t('a Network arrival asks the existing OAuth client silently and returns home', async () => {++ const { calls } = await visit({ referrer: 'https://openvibe.network/' });++ assert.deepStrictEqual(calls.navigations, ['/merchant/login?silent=1&next=%2F']);++});++++t('the hidden check accepts only Network from its own frame', async () => {++ const { calls, frame } = await visit({ pathname: '/merchant' });++ assert.strictEqual(calls.frames.length, 1);++ assert.match(frame.src, /^https:\/\/openvibe\.network\/sso\/check\?origin=/);++ calls.message({ origin: 'https://other.example', source: frame.contentWindow, data: { type: 'ov-sso', signedIn: true } });++ assert.deepStrictEqual(calls.navigations, []);++ calls.message({ origin: 'https://openvibe.network', source: {}, data: { type: 'ov-sso', signedIn: true } });++ assert.deepStrictEqual(calls.navigations, []);++ calls.message({ origin: 'https://openvibe.network', source: frame.contentWindow, data: { type: 'ov-sso', signedIn: true } });++ assert.deepStrictEqual(calls.navigations, ['/merchant/login?silent=1&next=%2Fmerchant']);++ assert.strictEqual(calls.removed, true);++});++++t('a failed silent OAuth attempt does not loop', async () => {++ const { calls } = await visit({ hint: 'account', search: '?sso=none' });++ assert.strictEqual(calls.fetch, 0);++ assert.deepStrictEqual(calls.navigations, []);++});++++t.run();@@@ -40,11 +40,26 @@ t('a guest sees the pitch and a sign-in link, not the form', async () => {assert.strictEqual(r.status, 200);assert.match(r.text, /List your store on OpenVibe/);assert.match(r.text, /Sign in with OpenVibe/);++ assert.match(r.text, /src="\/merchant\/arrival\.js"/);assert.match(r.text, /DNS TXT record or a meta tag/);assert.doesNotMatch(r.text, /name="listing_url"/);assert.strictEqual((await page('POST', '/merchant/sources', null, { redirect: 'manual', form: {} })).status, 303);});++t('silent arrival uses prompt none and accepts only the site home or merchant paths', async () => {++ const r = await page('GET', '/merchant/login?silent=1&next=%2F', null, { redirect: 'manual' });++ assert.strictEqual(r.status, 302);++ const target = new URL(r.headers.get('location'));++ assert.strictEqual(target.searchParams.get('prompt'), 'none');++ const { safeNext } = require('../server/merchant-auth');++ assert.strictEqual(safeNext('/'), '/');++ assert.strictEqual(safeNext('//evil.example'), '/merchant');++ assert.strictEqual(safeNext('https://evil.example'), '/merchant');++ const me = await page('GET', '/auth/me', ALICE_TOKEN);++ assert.strictEqual(me.status, 200);++ assert.strictEqual(JSON.parse(me.text).user.username, 'alice');++});++t('a signed-in merchant adds a domain and sees the DNS instructions', async () => {const list = await page('GET', '/merchant', ALICE_TOKEN);assert.strictEqual(list.status, 200);@@@ -28,11 +28,17 @@ t('browsers get a short honest HTML page in the network\'s kit: internal service// The kit's theme loader and kit-web from this host, and Cloudflare Web Analytics (injected at the edge); no inline script.assert.match(r.headers.get('content-security-policy'), /script-src 'self' https:\/\/static\.cloudflareinsights\.com;[^]*connect-src 'self' https:\/\/openvibe\.network https:\/\/cloudflareinsights\.com;/, 'the theme fetch from openvibe.network, and Cloudflare\'s report path on this host');assert.match(r.text, /<link rel="icon"[^>]*data-ov-icon="sources"/, 'the network\'s icon for Sources (no /favicon.ico 404)');-- for (const m of r.text.matchAll(/<script\b([^>]*)>/gi)) assert.match(m[1], /src="\/shared\//, 'every script is the kit\'s own file');++ for (const m of r.text.matchAll(/<script\b([^>]*)>/gi)) assert.match(m[1], /src="\/(?:shared\/|merchant\/arrival\.js)/, 'every script is served by this host');++ assert.match(r.headers.get('content-security-policy'), /frame-src https:\/\/openvibe\.network;/);++ assert.match(r.text, /src="\/merchant\/arrival\.js"/);++ assert.strictEqual((await request(svc.base, 'GET', '/merchant/arrival.js')).status, 200);assert.match(r.text, /<link rel="stylesheet" href="\/shared\/kit\.css">/, 'the network\'s kit');assert.match(r.text, /href="\/merchant"/, 'the merchant opt-in is one tap away');// The kit's files are served whether or not the merchant pages are on.assert.strictEqual((await request(svc.base, 'GET', '/shared/kit.css')).status, 200);++ const guest = await request(svc.base, 'GET', '/auth/me');++ assert.strictEqual(guest.status, 200);++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });assert.ok(!/admin/i.test(r.text));});
@@ -67,6 +67,7 @@ server {
@@ -67,6 +67,7 @@ server {
location = /api/ready { proxy_pass http://127.0.0.1:4720; }
location = /api/ready { proxy_pass http://127.0.0.1:4720; }
location = /release.json { proxy_pass http://127.0.0.1:4720; }
location = /release.json { proxy_pass http://127.0.0.1:4720; }
location = /auth/health { proxy_pass http://127.0.0.1:4720; }
location = /auth/health { proxy_pass http://127.0.0.1:4720; }
+ location = /auth/me { proxy_pass http://127.0.0.1:4720; }
# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+/* The Sources home and merchant guest page pick up an existing Network session. */
+(async () => {
+ 'use strict';
+ if (new URLSearchParams(location.search).get('sso') === 'none') return;
+ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return;
+ const hint = (document.cookie.match(/(?:^|;\s*)ov_sso_hint=([^;]*)/) || [])[1];
+ if (hint === 'guest') return;
+ try {
+ const r = await fetch('/auth/me', { credentials: 'same-origin', cache: 'no-store' });
+ if (r.ok && (await r.json()).user) return;
+ } catch { /* A failed check cannot grant a session. */ }
+
+ try {
+ if (Date.now() - Number(sessionStorage.getItem('ov_sources_silent_at') || 0) < 10 * 60 * 1000) return;
+ } catch { return; }
+ const signIn = () => {
+ try { sessionStorage.setItem('ov_sources_silent_at', String(Date.now())); } catch { return; }
+ const next = location.pathname === '/' ? '/' : '/merchant';
+ location.replace(`/merchant/login?silent=1&next=${encodeURIComponent(next)}`);
+ };
+ let fromNetwork = false;
+ try { fromNetwork = new URL(document.referrer).origin === 'https://openvibe.network'; } catch { /* no referrer */ }
+ if (hint === 'account' || fromNetwork) return signIn();
+
+ const frame = document.createElement('iframe');
+ frame.setAttribute('aria-hidden', 'true');
+ frame.setAttribute('tabindex', '-1');
+ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
+ const finish = (signedIn) => {
+ clearTimeout(timer);
+ window.removeEventListener('message', onMessage);
+ frame.remove();
+ if (signedIn) signIn();
+ };
+ const onMessage = (event) => {
+ if (event.origin !== 'https://openvibe.network' || event.source !== frame.contentWindow || event.data?.type !== 'ov-sso') return;
+ finish(event.data.signedIn === true);
+ };
+ window.addEventListener('message', onMessage);
+ const timer = setTimeout(() => finish(false), 4000);
+ frame.src = `https://openvibe.network/sso/check?origin=${encodeURIComponent(location.origin)}`;
+ document.body.appendChild(frame);
+})();
@@ -118,6 +118,12 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
@@ -118,6 +118,12 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
// GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it. Sources has
// GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it. Sources has
// no site-wide sign-in; the merchant flow is the one OAuth client, so it is the one this checks.
// no site-wide sign-in; the merchant flow is the one OAuth client, so it is the one this checks.
if (merchantAuth) {
if (merchantAuth) {
+ app.get('/auth/me', async (req, res, next) => {
+ try {
+ const person = await merchantAuth.resolvePage(req);
+ return res.set('Cache-Control', 'no-store').json({ user: person ? { id: person.subject, username: person.name } : null });
+ } catch (err) { return next(err); }
+ });
app.get('/auth/health', async (_req, res) => {
app.get('/auth/health', async (_req, res) => {
const client = await merchantAuth.checkClient();
const client = await merchantAuth.checkClient();
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
@@ -133,7 +139,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
@@ -133,7 +139,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
const networkOrigin = (() => { try { return new URL(config.networkUrl).origin; } catch { return 'https://openvibe.network'; } })();
const networkOrigin = (() => { try { return new URL(config.networkUrl).origin; } catch { return 'https://openvibe.network'; } })();
app.use('/merchant', (req, res, next) => {
app.use('/merchant', (req, res, next) => {
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
- res.setHeader('Content-Security-Policy', `default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self' ${networkOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
+ res.setHeader('Content-Security-Policy', `default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self' ${networkOrigin}; frame-src ${networkOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
next();
next();
}, express.urlencoded({ extended: false, limit: '32kb' }), merchantAuth.page, merchantPages({
}, express.urlencoded({ extended: false, limit: '32kb' }), merchantAuth.page, merchantPages({
db, merchant, registry, ingest, merchantAuth, limits, log,
db, merchant, registry, ingest, merchantAuth, limits, log,
@@ -147,11 +153,9 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
@@ -147,11 +153,9 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
res.setHeader('Vary', 'Accept');
res.setHeader('Vary', 'Accept');
if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {
if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {
- // The page runs no script of its own. Cloudflare Web Analytics: Cloudflare injects its beacon at the
- // edge and the privacy text says it may measure performance; script-src loads it, connect-src is
- // where it reports.
+ // The arrival script checks this host's merchant session and Network's silent SSO frame.
// The page is the kit's (views/home.js): the theme loader and kit-web from this host's /shared.
// The page is the kit's (views/home.js): the theme loader and kit-web from this host's /shared.
- res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; img-src 'self' data:; connect-src 'self' https://openvibe.network https://cloudflareinsights.com; base-uri 'none'; frame-ancestors 'none'");
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; img-src 'self' data:; connect-src 'self' https://openvibe.network https://cloudflareinsights.com; frame-src https://openvibe.network; base-uri 'none'; frame-ancestors 'none'");
return res.type('html').send(homePage());
return res.type('html').send(homePage());
}
}
return res.type('text/plain').send(TEXT_INDEX);
return res.type('text/plain').send(TEXT_INDEX);
@@ -52,6 +52,7 @@ function bearer(req) {
@@ -52,6 +52,7 @@ function bearer(req) {
function safeNext(value) {
function safeNext(value) {
const n = String(value == null ? '' : value);
const n = String(value == null ? '' : value);
if (!n || /[\u0000-\u001f\u007f\\]/.test(n)) return '/merchant';
if (!n || /[\u0000-\u001f\u007f\\]/.test(n)) return '/merchant';
+ if (n === '/') return '/';
if (!/^\/merchant(?:\/|$|\?|#)/.test(n)) return '/merchant';
if (!/^\/merchant(?:\/|$|\?|#)/.test(n)) return '/merchant';
return n;
return n;
}
}
@@ -165,7 +166,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
@@ -165,7 +166,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
const existing = await resolvePage(req);
const existing = await resolvePage(req);
if (existing) return res.redirect(303, next);
if (existing) return res.redirect(303, next);
// openvibe-sdk/sso: state + PKCE S256. The verifier rides the same /merchant flow cookie.
// openvibe-sdk/sso: state + PKCE S256. The verifier rides the same /merchant flow cookie.
- const started = sso.authorizeUrl();
+ const started = sso.authorizeUrl({ silent: req.query.silent === '1' });
res.cookie(STATE_COOKIE, started.state, flowCookie);
res.cookie(STATE_COOKIE, started.state, flowCookie);
res.cookie(VERIFIER_COOKIE, started.verifier, flowCookie);
res.cookie(VERIFIER_COOKIE, started.verifier, flowCookie);
res.cookie(NEXT_COOKIE, next, flowCookie);
res.cookie(NEXT_COOKIE, next, flowCookie);
@@ -180,7 +181,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
@@ -180,7 +181,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
res.clearCookie(STATE_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(STATE_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(VERIFIER_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(VERIFIER_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(NEXT_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(NEXT_COOKIE, { ...flowCookie, maxAge: undefined });
- if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}`);
+ if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}&sso=none`);
// The state (constant time) and the PKCE verifier must both come from this browser.
// The state (constant time) and the PKCE verifier must both come from this browser.
if (!code || !state || !expected || !verifier || String(state).length !== String(expected).length
if (!code || !state || !expected || !verifier || String(state).length !== String(expected).length
|| !crypto.timingSafeEqual(Buffer.from(String(state)), Buffer.from(String(expected)))) {
|| !crypto.timingSafeEqual(Buffer.from(String(state)), Buffer.from(String(expected)))) {
@@ -196,6 +197,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
@@ -196,6 +197,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
const u = await auth.verifyUser(data.access_token);
const u = await auth.verifyUser(data.access_token);
if (!u) throw Object.assign(new Error('the Network returned a token without a person subject'), { status: 401 });
if (!u) throw Object.assign(new Error('the Network returned a token without a person subject'), { status: 401 });
setSession(res, data.access_token);
setSession(res, data.access_token);
+ res.cookie('ov_sso_hint', 'account', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: SESSION_MAX_AGE });
return res.redirect(303, next);
return res.redirect(303, next);
} catch (err) {
} catch (err) {
log.error(`[merchant] sign-in failed: ${err.message}`);
log.error(`[merchant] sign-in failed: ${err.message}`);
@@ -205,6 +207,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
@@ -205,6 +207,7 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
function logout(req, res) {
function logout(req, res) {
clearSession(res);
clearSession(res);
+ res.cookie('ov_sso_hint', 'guest', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: SESSION_MAX_AGE });
return res.redirect(303, '/merchant');
return res.redirect(303, '/merchant');
}
}
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
* GET /merchant/login|callback|logout
* GET /merchant/login|callback|logout
*/
*/
const express = require('express');
const express = require('express');
+const path = require('path');
const { MerchantError } = require('./merchant');
const { MerchantError } = require('./merchant');
const views = require('./views/merchant');
const views = require('./views/merchant');
const { createMerchantView } = require('./api/merchant');
const { createMerchantView } = require('./api/merchant');
@@ -24,6 +25,8 @@ const NOTICES = {
@@ -24,6 +25,8 @@ const NOTICES = {
function merchantPages({ db, merchant, registry, ingest, merchantAuth, limits, log = console }) {
function merchantPages({ db, merchant, registry, ingest, merchantAuth, limits, log = console }) {
const router = express.Router();
const router = express.Router();
+ router.get('/arrival.js', (_req, res) => res.type('application/javascript').set('Cache-Control', 'public, max-age=300')
+ .sendFile(path.join(__dirname, '..', 'public', 'merchant-arrival.js')));
const decorate = createMerchantView({ db, merchant, registry, ingest });
const decorate = createMerchantView({ db, merchant, registry, ingest });
const readLimit = limits.budget('sources.merchant.read');
const readLimit = limits.budget('sources.merchant.read');
const manageLimit = limits.budget('sources.merchant.manage');
const manageLimit = limits.budget('sources.merchant.manage');
@@ -41,7 +41,7 @@ ${kit.section({
@@ -41,7 +41,7 @@ ${kit.section({
door('Source code', 'https://github.com/OpenVibers/OpenVibe.Sources', 'OpenVibers/OpenVibe.Sources on GitHub (AGPL-3.0)'),
door('Source code', 'https://github.com/OpenVibers/OpenVibe.Sources', 'OpenVibers/OpenVibe.Sources on GitHub (AGPL-3.0)'),
])),
])),
})}`;
})}`;
- return layout({ title: 'OpenVibe.Sources', description: 'The registry of outside sources the OpenVibe network reads, and where merchants list their store.', body })
+ return layout({ title: 'OpenVibe.Sources', description: 'The registry of outside sources the OpenVibe network reads, and where merchants list their store.', body, arrival: true })
// The layout titles its pages "… · OpenVibe.Sources"; the home is the site itself.
// The layout titles its pages "… · OpenVibe.Sources"; the home is the site itself.
.replace('<title>OpenVibe.Sources · OpenVibe.Sources</title>', '<title>OpenVibe.Sources</title>');
.replace('<title>OpenVibe.Sources · OpenVibe.Sources</title>', '<title>OpenVibe.Sources</title>');
}
}
@@ -27,7 +27,7 @@ const when = (iso) => {
@@ -27,7 +27,7 @@ const when = (iso) => {
const csrfInput = (csrf) => `<input type="hidden" name="csrf" value="${esc(csrf)}">`;
const csrfInput = (csrf) => `<input type="hidden" name="csrf" value="${esc(csrf)}">`;
-function layout({ title = 'List your store on OpenVibe', body = '', description = '' }) {
+function layout({ title = 'List your store on OpenVibe', body = '', description = '', arrival = false }) {
return `<!doctype html>
return `<!doctype html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -48,6 +48,7 @@ ${description ? `<meta name="description" content="${esc(description)}">` : ''}
@@ -48,6 +48,7 @@ ${description ? `<meta name="description" content="${esc(description)}">` : ''}
${body}
${body}
</main>
</main>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
+${arrival ? '<script src="/merchant/arrival.js" defer></script>' : ''}
</body>
</body>
</html>`;
</html>`;
}
}
@@ -74,7 +75,7 @@ ${kit.section({
@@ -74,7 +75,7 @@ ${kit.section({
body: `<p class="ovk-note">A merchant signs in with an OpenVibe.Network account. The claim belongs to that account.</p>
body: `<p class="ovk-note">A merchant signs in with an OpenVibe.Network account. The claim belongs to that account.</p>
<p>${kit.button({ href: '/merchant/login?next=%2Fmerchant', label: 'Sign in with OpenVibe', icon: 'user' })}</p>`,
<p>${kit.button({ href: '/merchant/login?next=%2Fmerchant', label: 'Sign in with OpenVibe', icon: 'user' })}</p>`,
})}`;
})}`;
- return layout({ body });
+ return layout({ body, arrival: true });
}
}
function claimRow(claim) {
function claimRow(claim) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { suite } = require('./helpers');
+
+const t = suite('merchant-arrival');
+const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'merchant-arrival.js'), 'utf8');
+
+async function visit({ hint = '', session = null, referrer = '', pathname = '/', search = '' } = {}) {
+ const calls = { fetch: 0, navigations: [], frames: [], message: null };
+ const frame = { contentWindow: {}, setAttribute() {}, style: {}, remove() { calls.removed = true; } };
+ const document = {
+ cookie: hint ? `ov_sso_hint=${hint}` : '', referrer,
+ createElement(tag) { assert.strictEqual(tag, 'iframe'); return frame; },
+ body: { appendChild(el) { calls.frames.push(el); } },
+ };
+ const window = {
+ addEventListener(type, fn) { assert.strictEqual(type, 'message'); calls.message = fn; },
+ removeEventListener() { calls.message = null; },
+ };
+ const storage = new Map();
+ await vm.runInNewContext(script, {
+ URL, URLSearchParams, document, window,
+ location: { origin: 'https://sources.openvibe.network', pathname, search, replace(url) { calls.navigations.push(url); } },
+ navigator: { userAgent: 'Chrome' },
+ sessionStorage: { getItem(k) { return storage.get(k) || null; }, setItem(k, v) { storage.set(k, v); } },
+ async fetch(url) { assert.strictEqual(url, '/auth/me'); calls.fetch++; return { ok: true, async json() { return { user: session }; } }; },
+ setTimeout() { return 1; }, clearTimeout() {},
+ });
+ return { calls, frame };
+}
+
+t('an explicit guest hint makes no session request or navigation', async () => {
+ const { calls } = await visit({ hint: 'guest', referrer: 'https://openvibe.network/' });
+ assert.strictEqual(calls.fetch, 0);
+ assert.deepStrictEqual(calls.navigations, []);
+ assert.strictEqual(calls.frames.length, 0);
+});
+
+t('an existing Sources session needs no new OAuth flow', async () => {
+ const { calls } = await visit({ hint: 'account', session: { username: 'alice' } });
+ assert.strictEqual(calls.fetch, 1);
+ assert.deepStrictEqual(calls.navigations, []);
+});
+
+t('a Network arrival asks the existing OAuth client silently and returns home', async () => {
+ const { calls } = await visit({ referrer: 'https://openvibe.network/' });
+ assert.deepStrictEqual(calls.navigations, ['/merchant/login?silent=1&next=%2F']);
+});
+
+t('the hidden check accepts only Network from its own frame', async () => {
+ const { calls, frame } = await visit({ pathname: '/merchant' });
+ assert.strictEqual(calls.frames.length, 1);
+ assert.match(frame.src, /^https:\/\/openvibe\.network\/sso\/check\?origin=/);
+ calls.message({ origin: 'https://other.example', source: frame.contentWindow, data: { type: 'ov-sso', signedIn: true } });
+ assert.deepStrictEqual(calls.navigations, []);
+ calls.message({ origin: 'https://openvibe.network', source: {}, data: { type: 'ov-sso', signedIn: true } });
+ assert.deepStrictEqual(calls.navigations, []);
+ calls.message({ origin: 'https://openvibe.network', source: frame.contentWindow, data: { type: 'ov-sso', signedIn: true } });
+ assert.deepStrictEqual(calls.navigations, ['/merchant/login?silent=1&next=%2Fmerchant']);
+ assert.strictEqual(calls.removed, true);
+});
+
+t('a failed silent OAuth attempt does not loop', async () => {
+ const { calls } = await visit({ hint: 'account', search: '?sso=none' });
+ assert.strictEqual(calls.fetch, 0);
+ assert.deepStrictEqual(calls.navigations, []);
+});
+
+t.run();
@@ -40,11 +40,26 @@ t('a guest sees the pitch and a sign-in link, not the form', async () => {
@@ -40,11 +40,26 @@ t('a guest sees the pitch and a sign-in link, not the form', async () => {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /List your store on OpenVibe/);
assert.match(r.text, /List your store on OpenVibe/);
assert.match(r.text, /Sign in with OpenVibe/);
assert.match(r.text, /Sign in with OpenVibe/);
+ assert.match(r.text, /src="\/merchant\/arrival\.js"/);
assert.match(r.text, /DNS TXT record or a meta tag/);
assert.match(r.text, /DNS TXT record or a meta tag/);
assert.doesNotMatch(r.text, /name="listing_url"/);
assert.doesNotMatch(r.text, /name="listing_url"/);
assert.strictEqual((await page('POST', '/merchant/sources', null, { redirect: 'manual', form: {} })).status, 303);
assert.strictEqual((await page('POST', '/merchant/sources', null, { redirect: 'manual', form: {} })).status, 303);
});
});
+t('silent arrival uses prompt none and accepts only the site home or merchant paths', async () => {
+ const r = await page('GET', '/merchant/login?silent=1&next=%2F', null, { redirect: 'manual' });
+ assert.strictEqual(r.status, 302);
+ const target = new URL(r.headers.get('location'));
+ assert.strictEqual(target.searchParams.get('prompt'), 'none');
+ const { safeNext } = require('../server/merchant-auth');
+ assert.strictEqual(safeNext('/'), '/');
+ assert.strictEqual(safeNext('//evil.example'), '/merchant');
+ assert.strictEqual(safeNext('https://evil.example'), '/merchant');
+ const me = await page('GET', '/auth/me', ALICE_TOKEN);
+ assert.strictEqual(me.status, 200);
+ assert.strictEqual(JSON.parse(me.text).user.username, 'alice');
+});
+
t('a signed-in merchant adds a domain and sees the DNS instructions', async () => {
t('a signed-in merchant adds a domain and sees the DNS instructions', async () => {
const list = await page('GET', '/merchant', ALICE_TOKEN);
const list = await page('GET', '/merchant', ALICE_TOKEN);
assert.strictEqual(list.status, 200);
assert.strictEqual(list.status, 200);
@@ -28,11 +28,17 @@ t('browsers get a short honest HTML page in the network\'s kit: internal service
@@ -28,11 +28,17 @@ t('browsers get a short honest HTML page in the network\'s kit: internal service
// The kit's theme loader and kit-web from this host, and Cloudflare Web Analytics (injected at the edge); no inline script.
// The kit's theme loader and kit-web from this host, and Cloudflare Web Analytics (injected at the edge); no inline script.
assert.match(r.headers.get('content-security-policy'), /script-src 'self' https:\/\/static\.cloudflareinsights\.com;[^]*connect-src 'self' https:\/\/openvibe\.network https:\/\/cloudflareinsights\.com;/, 'the theme fetch from openvibe.network, and Cloudflare\'s report path on this host');
assert.match(r.headers.get('content-security-policy'), /script-src 'self' https:\/\/static\.cloudflareinsights\.com;[^]*connect-src 'self' https:\/\/openvibe\.network https:\/\/cloudflareinsights\.com;/, 'the theme fetch from openvibe.network, and Cloudflare\'s report path on this host');
assert.match(r.text, /<link rel="icon"[^>]*data-ov-icon="sources"/, 'the network\'s icon for Sources (no /favicon.ico 404)');
assert.match(r.text, /<link rel="icon"[^>]*data-ov-icon="sources"/, 'the network\'s icon for Sources (no /favicon.ico 404)');
- for (const m of r.text.matchAll(/<script\b([^>]*)>/gi)) assert.match(m[1], /src="\/shared\//, 'every script is the kit\'s own file');
+ for (const m of r.text.matchAll(/<script\b([^>]*)>/gi)) assert.match(m[1], /src="\/(?:shared\/|merchant\/arrival\.js)/, 'every script is served by this host');
+ assert.match(r.headers.get('content-security-policy'), /frame-src https:\/\/openvibe\.network;/);
+ assert.match(r.text, /src="\/merchant\/arrival\.js"/);
+ assert.strictEqual((await request(svc.base, 'GET', '/merchant/arrival.js')).status, 200);
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/kit\.css">/, 'the network\'s kit');
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/kit\.css">/, 'the network\'s kit');
assert.match(r.text, /href="\/merchant"/, 'the merchant opt-in is one tap away');
assert.match(r.text, /href="\/merchant"/, 'the merchant opt-in is one tap away');
// The kit's files are served whether or not the merchant pages are on.
// The kit's files are served whether or not the merchant pages are on.
assert.strictEqual((await request(svc.base, 'GET', '/shared/kit.css')).status, 200);
assert.strictEqual((await request(svc.base, 'GET', '/shared/kit.css')).status, 200);
+ const guest = await request(svc.base, 'GET', '/auth/me');
+ assert.strictEqual(guest.status, 200);
+ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
assert.ok(!/admin/i.test(r.text));
assert.ok(!/admin/i.test(r.text));
});
});